From 5520b59b5afd189611b34480b9f8389b59654862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:19:12 +0000 Subject: [PATCH 1/9] Read trading allowance claimants from the input data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add trading_allowance_claim_status, a person enum (UNKNOWN, CLAIMANT, NON_CLAIMANT; default UNKNOWN), and gov.simulation.trading_allowance_in_input_profits, the allowance a claimant's input profit already deducts: £1,000 from 2017-18, not moved by reforms to the trading allowance and not uprated. A claimant's receipts are their profit plus that amount, so the deduction at allowance A is min(profit, A - £1,000) less capital allowances, floored at nil, and a cut below £1,000 leaves their profit as reported. A non-claimant gets no allowance at any level, on trade receipts or on income not otherwise charged. Unknown keeps the current rules, so existing datasets are unchanged. Class 4 and the current-loss election read the same deduction. Issue #2764. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2764.added.md | 1 + .../trading_allowance_in_input_profits.yaml | 26 ++ .../trading_allowance_claim_status.yaml | 362 ++++++++++++++++++ ...ading_allowance_claim_status_properties.py | 235 ++++++++++++ policyengine_uk/utils/trading_allowance.py | 52 ++- ...eous_income_trading_allowance_deduction.py | 3 +- .../allowances/trading_allowance_deduction.py | 13 +- .../input/income_not_otherwise_charged.py | 5 +- .../input/self_employment_gross_receipts.py | 4 +- .../input/trading_allowance_claim_status.py | 51 +++ 10 files changed, 740 insertions(+), 12 deletions(-) create mode 100644 changelog.d/2764.added.md create mode 100644 policyengine_uk/parameters/gov/simulation/trading_allowance_in_input_profits.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml create mode 100644 policyengine_uk/tests/test_trading_allowance_claim_status_properties.py create mode 100644 policyengine_uk/variables/input/trading_allowance_claim_status.py diff --git a/changelog.d/2764.added.md b/changelog.d/2764.added.md new file mode 100644 index 0000000000..b47c75d7f1 --- /dev/null +++ b/changelog.d/2764.added.md @@ -0,0 +1 @@ +Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus `gov.simulation.trading_allowance_in_input_profits` (£1,000 from 2017-18, which reforms to the allowance do not move and which is not uprated), so a raise to any level relieves them by the allowance's excess over £1,000, up to their profit, and a cut below £1,000 leaves their profit as reported. A non-claimant gets no allowance at any level, on trade receipts or on income not otherwise charged. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. diff --git a/policyengine_uk/parameters/gov/simulation/trading_allowance_in_input_profits.yaml b/policyengine_uk/parameters/gov/simulation/trading_allowance_in_input_profits.yaml new file mode 100644 index 0000000000..950ce8b9cc --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/trading_allowance_in_input_profits.yaml @@ -0,0 +1,26 @@ +description: Trading allowance already deducted in the self-employment profit of a person whose trading_allowance_claim_status is claimant. Their receipts are that profit plus this amount, so the trading allowance relieves them by its excess over this amount, up to their profit. This describes the input data, not policy, so reforms to the trading allowance do not move it and it is not uprated. Change it only for input data measured under a different allowance, such as data for 2027-28 or later. +# Survey data from 2017-18 to 2026-27 were measured under the £1,000 +# allowance, which the model keeps here past the April 2027 raise to £3,000 +# (gov.hmrc.income_tax.allowances.trading_allowance): a claimant flagged in +# such data and projected to a later year still deducted £1,000. Before +# 2017-18 there was no allowance to deduct. +values: + 2005-04-01: + value: 0 + reference: + - title: Finance (No. 2) Act 2017 Sch. 3 para. 13 (allowance has effect from 2017-18) + href: https://www.legislation.gov.uk/ukpga/2017/32/schedule/3/paragraph/13 + 2017-04-06: + value: 1_000 + reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 783AD + href: https://www.legislation.gov.uk/ukpga/2005/5/section/783AD + - title: Finance (No. 2) Act 2017 Sch. 3 + href: https://www.legislation.gov.uk/ukpga/2017/32/schedule/3 +metadata: + label: Trading allowance deducted in input profits + period: year + unit: currency-GBP + reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 783AI (partial relief) + href: https://www.legislation.gov.uk/ukpga/2005/5/section/783AI diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml new file mode 100644 index 0000000000..dad4810334 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml @@ -0,0 +1,362 @@ +# trading_allowance_claim_status says whether a person's input profit already +# deducts the trading allowance, as on a return claiming partial relief +# (ITTOIA 2005 s. 783AI). The allowance deducted in the input data is +# gov.simulation.trading_allowance_in_input_profits, £1,000 from 2017-18, +# which reforms to the trading allowance do not move. +# - Claimant: receipts are profit + £1,000, so the deduction at allowance A is +# min(profit, A - £1,000), and nil at or below £1,000. +# - Non-claimant: actual expenses above £1,000; no deduction at any A. +# - Unknown (the default): unchanged. +# The allowance is £1,000 in 2026-27 and £3,000 from 2027-28 (Budget 2026, +# paragraph 6.254). Personal allowance £12,570, basic rate 20%, Class 4 main +# rate 6% above the £12,570 lower profits limit. + +- name: A claimant at the input allowance keeps their profit as reported. + period: 2026 + absolute_error_margin: 0 + input: + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 + +- name: A claimant's profit below the allowance already deducts it. + period: 2026 + absolute_error_margin: 0 + input: + self_employment_income: 600 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts of £1,600 are above the allowance, so the £600 is receipts + # less the allowance. Unknown status would relieve it in full. + trading_allowance_deduction: 0 + taxable_self_employment_income: 600 + +- name: The raise to £3,000 gives a claimant £2,000 more. + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts 2,500 + 1,000 = 3,500, less the £3,000 allowance. + trading_allowance_deduction: 2_000 + taxable_self_employment_income: 500 + # (30,000 + 500 - 12,570) x 20% + income_tax: 3_586 + +- name: A claimant whose receipts are within £3,000 is fully relieved. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 2_000 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts 2,000 + 1,000 = 3,000 (s. 783AF). + trading_allowance_deduction: 2_000 + taxable_self_employment_income: 0 + +- name: A raise to £5,000 gives a claimant £4,000 more. + period: 2027 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 5_000 + self_employment_income: 6_000 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts 6,000 + 1,000 = 7,000, less the £5,000 allowance. + trading_allowance_deduction: 4_000 + taxable_self_employment_income: 2_000 + +- name: A claimant whose receipts are within £5,000 is fully relieved. + period: 2027 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 5_000 + self_employment_income: 3_500 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts 3,500 + 1,000 = 4,500. + trading_allowance_deduction: 3_500 + taxable_self_employment_income: 0 + +- name: A cut to £500 leaves a claimant's profit as reported. + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 500 + people: + claimant: + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + unknown: + self_employment_income: 800 + benunits: + benunit: + members: [claimant, unknown] + households: + household: + members: [claimant, unknown] + output: + # The claimant's receipts are £3,500 and their actual expenses are below + # £1,000 but unknown. Still claiming, the Act would charge + # 3,500 - 500 = 3,000; with expenses of £1,000, 2,500. The model takes + # the lower figure. The unknown-status £800 profit is above £500 and + # loses its full relief. + # [claimant, unknown] + trading_allowance_deduction: [0, 0] + taxable_self_employment_income: [2_500, 800] + +- name: Abolishing the allowance leaves a claimant's profit as reported. + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 0 + people: + claimant: + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + unknown: + self_employment_income: 800 + benunits: + benunit: + members: [claimant, unknown] + households: + household: + members: [claimant, unknown] + output: + # [claimant, unknown] + trading_allowance_deduction: [0, 0] + taxable_self_employment_income: [2_500, 800] + +- name: Input data measured under the £3,000 allowance gain nothing from it. + period: 2027 + absolute_error_margin: 0 + input: + gov.simulation.trading_allowance_in_input_profits: 3_000 + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 + +- name: A claimant's status takes precedence over supplied receipts. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 6_000 + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts are read as 2,500 + 1,000 = 3,500, not 6,000. + trading_allowance_deduction: 2_000 + taxable_self_employment_income: 500 + +- name: A non-claimant's profit below the allowance is taxed. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 600 + trading_allowance_claim_status: NON_CLAIMANT + output: + # Actual expenses above £1,000 put receipts above £1,600. Unknown status + # would relieve the £600 in full. + trading_allowance_deduction: 0 + taxable_self_employment_income: 600 + +- name: A raise to £5,000 gives a non-claimant nothing. + period: 2027 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 5_000 + people: + non_claimant: + self_employment_income: 2_500 + trading_allowance_claim_status: NON_CLAIMANT + unknown: + self_employment_income: 4_000 + benunits: + benunit: + members: [non_claimant, unknown] + households: + household: + members: [non_claimant, unknown] + output: + # The unknown-status £4,000 profit is within £5,000 and relieved in full. + # [non_claimant, unknown] + trading_allowance_deduction: [0, 4_000] + taxable_self_employment_income: [2_500, 0] + +- name: A non-claimant's status takes precedence over supplied receipts. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 4_000 + self_employment_income: 2_500 + trading_allowance_claim_status: NON_CLAIMANT + output: + # With £1,500 of expenses, electing for the £3,000 allowance would + # charge £1,000. That switch is a change in behaviour. + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 + +- name: Unknown status still relieves a profit within the allowance. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 2_500 + trading_allowance_claim_status: UNKNOWN + output: + trading_allowance_deduction: 2_500 + taxable_self_employment_income: 0 + +- name: Unknown status still applies the allowance to supplied receipts. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 5_000 + self_employment_income: 4_000 + trading_allowance_claim_status: UNKNOWN + output: + trading_allowance_deduction: 2_000 + taxable_self_employment_income: 2_000 + +- name: Class 4 reads a claimant's deduction. + period: 2027 + absolute_error_margin: 0.01 + input: + self_employment_income: 20_000 + trading_allowance_claim_status: CLAIMANT + output: + trading_allowance_deduction: 2_000 + ni_class_4_profits_before_losses: 18_000 + # (18,000 - 12,570) x 6% + ni_class_4: 325.80 + # (18,000 - 12,570) x 20% + income_tax: 1_086 + +- name: Class 4 charges a non-claimant's whole profit. + period: 2027 + absolute_error_margin: 0.01 + input: + self_employment_income: 20_000 + trading_allowance_claim_status: NON_CLAIMANT + output: + trading_allowance_deduction: 0 + ni_class_4_profits_before_losses: 20_000 + # (20,000 - 12,570) x 6% + ni_class_4: 445.80 + # (20,000 - 12,570) x 20% + income_tax: 1_486 + +- name: A claimant's £2,000 more beats a smaller current loss. + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 1_500 + trading_allowance_claim_status: CLAIMANT + output: + # The allowance saves £2,000 against a £1,500 loss, and makes the loss + # nil (ss. 783AF and 783AI). + trading_allowance_deduction: 2_000 + uses_trading_allowance: true + trade_loss_relief_against_general_income: 0 + ni_class_4_trading_loss: 0 + # (30,000 + 500 - 12,570) x 20% + income_tax: 3_586 + +- name: A current loss larger than a claimant's £2,000 is claimed instead. + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + trading_allowance_deduction: 0 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 2_500 + ni_class_4_trading_loss: 2_500 + # (30,000 + 2,500 - 2,500 - 12,570) x 20% + income_tax: 3_486 + +- name: A non-claimant claims a current loss the allowance would have taken. + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_income: 600 + trading_loss: 500 + trading_allowance_claim_status: NON_CLAIMANT + output: + # Unknown status would relieve the £600 profit and give up the loss. + trading_allowance_deduction: 0 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 500 + # (30,000 + 600 - 500 - 12,570) x 20% + income_tax: 3_506 + +- name: A claimant's receipts and casual income within £3,000 are both relieved. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 500 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts of £1,500 and casual income of £900 (s. 783AF and 783AG). + trading_allowance_deduction: 500 + taxable_self_employment_income: 0 + miscellaneous_income_trading_allowance_deduction: 900 + taxable_miscellaneous_income: 0 + +- name: Casual income gets what a claimant's receipts leave of £3,000. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 1_800 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts of £2,800 take £2,800 of the allowance first (s. 783AK). + trading_allowance_deduction: 1_800 + taxable_self_employment_income: 0 + miscellaneous_income_trading_allowance_deduction: 200 + taxable_miscellaneous_income: 700 + +- name: At the input allowance a claimant's receipts leave none for casual income. + period: 2026 + absolute_error_margin: 0 + input: + self_employment_income: 500 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts of £1,500 are above the £1,000 allowance. + trading_allowance_deduction: 0 + taxable_self_employment_income: 500 + miscellaneous_income_trading_allowance_deduction: 0 + taxable_miscellaneous_income: 900 + +- name: A non-claimant's casual income gets no allowance. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 500 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + trading_allowance_claim_status: NON_CLAIMANT + output: + # Not electing for partial relief covers all relevant income (s. 783AM). + trading_allowance_deduction: 0 + miscellaneous_income_trading_allowance_deduction: 0 + taxable_miscellaneous_income: 900 + uses_trading_allowance: false diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py new file mode 100644 index 0000000000..2aaaaf67ec --- /dev/null +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py @@ -0,0 +1,235 @@ +"""Property tests for trading_allowance_claim_status (ITTOIA 2005 Part 6A, +Chapter 1). + +Survey data hold profit, not receipts. The claim status says which receipts +lie behind it. A claimant's input profit already deducts the allowance in +force when it was measured (gov.simulation.trading_allowance_in_input_profits, +£1,000), so their receipts are that profit plus £1,000. A non-claimant deducts +actual expenses above £1,000. Unknown, the default, keeps the rules for +supplied or missing receipts. + +Invariants, each checked over many generated people in one reusable +simulation per allowance level, from abolition to well above the £3,000 of +2027-28: + +1. Every status matches a reference written from the Act. A claimant's + taxable trade profit is the lower of ordinary treatment (the input profit, + with expenses read as £1,000) and the allowance on receipts of profit plus + £1,000, whatever receipts are supplied. Their income not otherwise charged + gets what those receipts leave of the allowance. A non-claimant gets + neither deduction. Unknown status gives what it gave before the status + existed. A current loss larger than the allowance's saving is claimed + instead. +2. A claimant's two deductions never exceed the allowance's excess over + £1,000, so a cut to £1,000 or below leaves their profit as reported. +3. A larger allowance never raises taxable trade profit, for any status. +4. Class 4 reads the same deduction as income tax. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2027 +INPUT_ALLOWANCE = 1_000 +ALLOWANCES = [0, 500, 1_000, 2_000, 3_000, 5_000, 10_000] +STATUSES = ["UNKNOWN", "CLAIMANT", "NON_CLAIMANT"] +PEOPLE = 30 + +amount = st.one_of( + st.floats(min_value=0, max_value=12_000), + st.floats(min_value=0, max_value=100_000), + st.sampled_from([0.0, 500.0, 999.99, 1_000.0, 2_000.0, 3_000.0, 4_000.0]), +) +case = st.tuples( + st.sampled_from(STATUSES), + st.one_of(amount, st.floats(min_value=-3_000, max_value=0)), # profit + amount, # actual expenses + st.sampled_from(["unknown", "consistent", "below_profit"]), # receipts + st.sampled_from([0.0, 0.0, 150.0, 2_500.0]), # capital allowances + st.sampled_from([0.0, 0.0, 0.0, 300.0, 2_000.0]), # losses brought forward + st.sampled_from([0.0, 0.0, 400.0, 900.0, 5_000.0]), # Chapter 8 income + st.sampled_from([0.0, 0.0, 0.0, 0.0, 300.0, 3_000.0]), # current loss +) +cases = st.lists(case, min_size=PEOPLE, max_size=PEOPLE) + + +@pytest.fixture(scope="module") +def sims(): + people = {f"person_{i}": {"age": {YEAR: 40}} for i in range(PEOPLE)} + situation = { + "people": people, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": { + f"household_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + } + return { + allowance: Simulation( + situation=situation, + reform={ + "gov.hmrc.income_tax.allowances.trading_allowance": { + str(YEAR): allowance + } + }, + ) + for allowance in ALLOWANCES + } + + +def run(sim, drawn): + status, profit, expenses, mode, capital, brought_forward, chapter_8, loss = ( + np.array(x) for x in zip(*drawn) + ) + receipts = np.select( + [mode == "consistent", mode == "below_profit"], + [np.maximum(profit, 0) + expenses, 0.5 * np.maximum(profit, 0)], + 0, + ) + sim.set_input("trading_allowance_claim_status", YEAR, status) + sim.set_input("self_employment_income", YEAR, profit) + sim.set_input("self_employment_gross_receipts", YEAR, receipts) + sim.set_input("capital_allowances", YEAR, capital) + sim.set_input("loss_relief", YEAR, brought_forward) + sim.set_input("miscellaneous_income", YEAR, chapter_8) + sim.set_input("income_not_otherwise_charged", YEAR, chapter_8) + sim.set_input("trading_loss", YEAR, loss) + sim.reset_calculations() + + # Compare against the stored (float32) inputs, not the float64 draws. + def stored(name): + return sim.calculate(name, YEAR).astype(float) + + return dict( + status=status, + profit=stored("self_employment_income"), + receipts=stored("self_employment_gross_receipts"), + capital=stored("capital_allowances"), + brought_forward=stored("loss_relief"), + chapter_8=stored("income_not_otherwise_charged"), + loss=stored("trading_loss"), + trade_relief=stored("trading_allowance_deduction"), + chapter_8_relief=stored("miscellaneous_income_trading_allowance_deduction"), + taxable=stored("taxable_self_employment_income"), + class_4_profits=stored("ni_class_4_profits_before_losses"), + ) + + +def reference(r, allowance): + """Taxable trade profit and the Chapter 8 deduction, from the Act. + + Receipts within the allowance are fully relieved (s. 783AF). Above it the + person takes the better of actual deductions and the allowance (s. + 783AI), and Chapter 8 income gets the part of the allowance the trade + receipts leave (s. 783AK, trades first). Without receipts, a profit within + the allowance is relieved in full and a larger one is taxed as reported. + """ + profit, capital = r["profit"], r["capital"] + claimant = r["status"] == "CLAIMANT" + non_claimant = r["status"] == "NON_CLAIMANT" + supplied = (r["receipts"] > 0) & (r["receipts"] >= profit) + receipts = np.where( + claimant, np.maximum(profit, 0) + INPUT_ALLOWANCE, r["receipts"] + ) + known = claimant | supplied + # A claimant's input profit is ordinary treatment with expenses of + # £1,000: receipts less the input allowance. + ordinary = profit - capital + with_allowance = np.where( + known, + np.where( + receipts <= allowance, + 0, + np.minimum(ordinary, receipts - allowance), + ), + np.where(profit <= allowance, 0, ordinary), + ) + with_allowance = np.where(non_claimant, ordinary, with_allowance) + trade_saving = np.maximum(ordinary - with_allowance, 0) + # The trade receipts that take up the allowance first. + used = np.where(known, receipts, np.minimum(np.maximum(profit, 0), allowance)) + chapter_8_relief = np.where( + non_claimant, + 0, + np.minimum(r["chapter_8"], np.maximum(allowance - used, 0)), + ) + # The election: a larger current loss is claimed instead (s. 783AL and + # s. 783AM), and then neither deduction applies. + saving = ( + np.minimum(trade_saving, np.maximum(ordinary - r["brought_forward"], 0)) + + chapter_8_relief + ) + claim_loss = r["loss"] > saving + taxable_before_losses = np.where(claim_loss, ordinary, ordinary - trade_saving) + taxable = np.maximum(taxable_before_losses - r["brought_forward"], 0) + return taxable, np.where(claim_loss, 0, chapter_8_relief) + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 1e-6 * sum(np.abs(v) for v in values) + + +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases) +def test_every_status_matches_the_act_at_every_allowance(sims, drawn): + for allowance, sim in sims.items(): + r = run(sim, drawn) + taxable, chapter_8_relief = reference(r, allowance) + margin = tolerance(r["receipts"], r["profit"], allowance) + assert (np.abs(r["taxable"] - taxable) <= margin).all(), allowance + assert (np.abs(r["chapter_8_relief"] - chapter_8_relief) <= margin).all(), ( + allowance + ) + + +@SETTINGS +@given(cases) +def test_a_claimant_gets_only_the_allowances_excess_over_the_input_allowance( + sims, drawn +): + for allowance, sim in sims.items(): + r = run(sim, drawn) + claimant = r["status"] == "CLAIMANT" + relief = r["trade_relief"] + r["chapter_8_relief"] + excess = max(allowance - INPUT_ALLOWANCE, 0) + assert (relief[claimant] <= excess + tolerance(allowance)).all(), allowance + if allowance <= INPUT_ALLOWANCE: + reported = np.maximum(r["profit"] - r["capital"] - r["brought_forward"], 0) + assert ( + np.abs(r["taxable"] - reported)[claimant] + <= tolerance(r["profit"])[claimant] + ).all(), allowance + + +@SETTINGS +@given(cases) +def test_a_larger_allowance_never_raises_taxable_profit(sims, drawn): + taxable = [run(sims[allowance], drawn)["taxable"] for allowance in ALLOWANCES] + for smaller, larger in zip(taxable, taxable[1:]): + assert (larger <= smaller + tolerance(smaller)).all() + + +@SETTINGS +@given(cases) +def test_class_4_reads_the_same_deduction(sims, drawn): + for allowance, sim in sims.items(): + r = run(sim, drawn) + expected = np.maximum(r["profit"] - r["capital"] - r["trade_relief"], 0) + assert ( + np.abs(r["class_4_profits"] - expected) <= tolerance(r["profit"]) + ).all(), allowance diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index d16a3b54f0..0494d2358e 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -60,6 +60,26 @@ def trading_allowance_reliefs(person, period, parameters): income. A larger profit is taken to reflect the better of actual expenses and the allowance already, which leaves none of it. + trading_allowance_claim_status, when known, takes precedence over gross + receipts: + - A claimant's profit is their receipts less the allowance deducted in + the input data (gov.simulation.trading_allowance_in_input_profits), + so their receipts are their profit plus it, and the model reads that + amount as their actual expenses. The relief is then the allowance's + excess over it, less capital allowances (nil for a consistent + claimant, since partial relief replaces them), capped at profit. A + cut below it gives nil: their actual expenses are below the input + allowance but unknown, so their taxable profit would rise by between + nil and the input allowance less the new one, and the model takes + nil, keeping the deduction a relief beyond what the input profit + deducts. Their receipts take up the allowance before income not + otherwise charged, as other receipts do. + - A non-claimant's actual expenses and capital allowances exceed the + input allowance, and their receipts are unknown. Electing for partial + relief after a raise is a change in behaviour, so neither deduction + applies at any allowance. Full relief needs no election, but needs + relevant income within the allowance, which the data cannot show. + The miscellaneous income is income_not_otherwise_charged, up to the person's miscellaneous_income when that is positive (it is part of it). The model holds no expenses against it. Not modelled: the exclusions in @@ -67,21 +87,34 @@ def trading_allowance_reliefs(person, period, parameters): from the person's employer, firm or close company), and a loss created by capital allowances, which the model never sets against other income. """ - allowance = parameters(period).gov.hmrc.income_tax.allowances.trading_allowance + p = parameters(period) + allowance = p.gov.hmrc.income_tax.allowances.trading_allowance + input_allowance = p.gov.simulation.trading_allowance_in_input_profits + claim_status = person("trading_allowance_claim_status", period) + claimant = claim_status == claim_status.possible_values.CLAIMANT + non_claimant = claim_status == claim_status.possible_values.NON_CLAIMANT profit = person("self_employment_income", period) - receipts = person("self_employment_gross_receipts", period) capital_allowances = person("capital_allowances", period) - # Receipts below profit (including the default zero) mean unknown. - receipts_known = (receipts > 0) & (receipts >= profit) + # A claimant's receipts are their profit plus the allowance it deducts. + receipts = where( + claimant, + max_(profit, 0) + input_allowance, + person("self_employment_gross_receipts", period), + ) + # Otherwise receipts below profit (including the default zero) mean + # unknown. + receipts_known = claimant | ((receipts > 0) & (receipts >= profit)) expenses = max_(receipts - profit, 0) # Full relief (receipts within the allowance, s. 783AE-783AF) makes # the profit nil; partial relief (s. 783AI) replaces expenses and # capital allowances with the allowance. Both come to the allowance's - # excess over actual deductions, capped at profit. + # excess over actual deductions, capped at profit. For a claimant the + # input allowance stands in for their expenses. relief_with_receipts = min_(profit, allowance - expenses) - capital_allowances relief_without_receipts = where(profit <= allowance, profit - capital_allowances, 0) trade_relief = where(receipts_known, relief_with_receipts, relief_without_receipts) - trade_relief = max_(trade_relief, 0) + # A non-claimant deducts actual expenses above the input allowance. + trade_relief = where(non_claimant, 0, max_(trade_relief, 0)) # The trade receipts that use the allowance first. Unknown receipts are # the profit within the allowance, and otherwise use all of it. @@ -94,8 +127,11 @@ def trading_allowance_reliefs(person, period, parameters): max_(person("income_not_otherwise_charged", period), 0), max_(person("miscellaneous_income", period), 0), ) - miscellaneous_relief = min_( - miscellaneous_income, max_(allowance - trade_receipts, 0) + # A non-claimant takes ordinary treatment of all their relevant income. + miscellaneous_relief = where( + non_claimant, + 0, + min_(miscellaneous_income, max_(allowance - trade_receipts, 0)), ) # Full relief makes every relevant trade's current profit AND loss diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py index 8b48624fdc..c320612c74 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py @@ -20,7 +20,8 @@ class miscellaneous_income_trading_allowance_deduction(Variable): "contributions exceed relevant UK earnings (see " "utils/trading_allowance.py). Ordinary treatment to claim a current " "trading loss (no full relief, s. 783AL, or no partial relief, s. " - "783AM) leaves it unrelieved. Only " + "783AM) leaves it unrelieved, as it does for a non-claimant " + "(trading_allowance_claim_status). Only " "income tax reads this deduction, through taxable_miscellaneous_income; " "means tests and gross income bases read " "miscellaneous_income_before_trading_allowance. Datasets that hold " 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 565a4957d9..528d0e815f 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 @@ -18,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. The model compares the raw loss " + "inconsistent and treated as unknown. trading_allowance_claim_status, " + "when known, takes precedence over receipts. A claimant's profit " + "already deducts the allowance in force when it was measured " + "(gov.simulation.trading_allowance_in_input_profits), so their " + "receipts are their profit plus it and the deduction is the " + "allowance's excess over it, capped at profit. A cut below that " + "amount would raise a claimant's taxable profit by up to the " + "difference, depending on expenses the data do not hold; the model " + "leaves it as reported. A non-claimant deducts actual expenses above " + "it and gets no deduction at any allowance, since electing for the " + "allowance after a raise is a change in behaviour. " + "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, " "including its relief on miscellaneous income. " diff --git a/policyengine_uk/variables/input/income_not_otherwise_charged.py b/policyengine_uk/variables/input/income_not_otherwise_charged.py index 615a6ec09a..52449a6585 100644 --- a/policyengine_uk/variables/input/income_not_otherwise_charged.py +++ b/policyengine_uk/variables/input/income_not_otherwise_charged.py @@ -19,7 +19,10 @@ class income_not_otherwise_charged(Variable): "Part 5 Chapters and, on SPI records, benefits and termination " "payments, none of which the allowance relieves. Without " "self_employment_gross_receipts, a trade with a loss or no profit is " - "taken to have no receipts, so its relevant income is this alone." + "taken to have no receipts, so its relevant income is this alone. " + "A claimant's trade receipts (trading_allowance_claim_status) take up " + "the allowance first, and a non-claimant gets none of it on this " + "income." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/self_employment_gross_receipts.py b/policyengine_uk/variables/input/self_employment_gross_receipts.py index c7ff04c188..a276020fa2 100644 --- a/policyengine_uk/variables/input/self_employment_gross_receipts.py +++ b/policyengine_uk/variables/input/self_employment_gross_receipts.py @@ -10,7 +10,9 @@ class self_employment_gross_receipts(Variable): "Optional: used only to apply the trading allowance, which is measured " "against gross receipts and replaces actual expenses. Must be at least " "self_employment_income; leave at zero when unknown. " - "self_employment_income remains the profit used everywhere else." + "self_employment_income remains the profit used everywhere else. " + "A claimant or non-claimant trading_allowance_claim_status takes " + "precedence over it." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/trading_allowance_claim_status.py b/policyengine_uk/variables/input/trading_allowance_claim_status.py new file mode 100644 index 0000000000..1fe4b18bf7 --- /dev/null +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -0,0 +1,51 @@ +from policyengine_uk.model_api import * + + +class TradingAllowanceClaimStatus(Enum): + UNKNOWN = "Unknown" + CLAIMANT = "Claims the trading allowance" + NON_CLAIMANT = "Does not claim the trading allowance" + + +class trading_allowance_claim_status(Variable): + value_type = Enum + possible_values = TradingAllowanceClaimStatus + default_value = TradingAllowanceClaimStatus.UNKNOWN + entity = Person + label = "trading allowance claim status in the input data" + documentation = ( + "Whether the person claimed the trading allowance in the tax year " + "their input self-employment profit was measured. Survey data record " + "profit, not gross receipts (turnover), and this says which receipts " + "lie behind it, so that a change to the allowance reaches the right " + "people. CLAIMANT: the profit is receipts less the allowance in force " + "when it was measured (gov.simulation.trading_allowance_in_input_profits), " + "as on a return claiming partial relief (ITTOIA 2005 s. 783AI), so the " + "receipts are that profit plus that allowance. Flag only profits net " + "of the allowance: a person whose receipts were within it was fully " + "relieved, and their profit, if reported, is not net of it; leave them " + "UNKNOWN. NON_CLAIMANT: the profit is receipts less actual expenses and " + "capital allowances above that allowance. Their receipts are unknown, " + "and electing for the allowance after a raise is a change in " + "behaviour, so the allowance relieves neither their trades nor their " + "income not otherwise charged, at any level. UNKNOWN, the default: " + "the allowance applies to self_employment_gross_receipts when they " + "are supplied, and otherwise treats a profit within the allowance as " + "receipts within it. A claimant or non-claimant status takes " + "precedence over self_employment_gross_receipts. For profits already " + "net of the allowance, marking claimants and non-claimants here gives " + "no deduction while the allowance stays at the input allowance, as " + "setting trading_allowance_deduction to zero does, but lets a change " + "to the allowance reach them." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783AI (partial relief)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AI", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 783AM (election for partial relief)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AM", + ), + ] From 4d5b6c1bd6584ba3991bb93a3a3170c5f8e51177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:17:22 +0000 Subject: [PATCH 2/9] Address review: full relief, data-year allowance, claimant elections Non-claimants keep full relief, which needs no election (ITTOIA 2005 s. 783AE), where supplied receipts, or casual income alone with no profit, are within the allowance; partial relief stays elective. One ordinary-treatment mask replaces the two zeroing masks. A claimant's input allowance is now the allowance legislated for the year the data were measured (a dataset's first year, or the year simulated), read from the unreformed baseline parameters, so data from either side of the April 2027 raise read right. This replaces gov.simulation.trading_allowance_in_input_profits. Claimants: capital allowances are not deducted (partial relief replaces them), the status applies only to a positive profit, the deduction is computed from the allowances directly (no float32 round trip), and the claim is their election, so they take the allowance and give up current losses at any allowance above nil. A cut's effect is set by the new gov.simulation.trading_allowance_claimant_expense_share (default 1, the lower bound), which the trading allowance's description points to. Tests: YAML cases for each review point, a property test whose reference works from the Act's options, and dataset tests for the data year and the status carrying forward. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2764.added.md | 2 +- .../allowances/trading_allowance.yaml | 2 +- ...ding_allowance_claimant_expense_share.yaml | 19 ++ .../trading_allowance_in_input_profits.yaml | 26 -- .../trading_allowance_claim_status.yaml | 321 ++++++++++++++---- ...rading_allowance_claim_status_data_year.py | 133 ++++++++ ...ading_allowance_claim_status_properties.py | 241 +++++++------ policyengine_uk/utils/trading_allowance.py | 136 +++++--- ...eous_income_trading_allowance_deduction.py | 19 +- .../allowances/trading_allowance_deduction.py | 28 +- .../allowances/uses_trading_allowance.py | 15 +- .../input/income_not_otherwise_charged.py | 10 +- .../input/self_employment_gross_receipts.py | 5 +- .../input/trading_allowance_claim_status.py | 50 +-- 14 files changed, 728 insertions(+), 279 deletions(-) create mode 100644 policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml delete mode 100644 policyengine_uk/parameters/gov/simulation/trading_allowance_in_input_profits.yaml create mode 100644 policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py diff --git a/changelog.d/2764.added.md b/changelog.d/2764.added.md index b47c75d7f1..89b6011e18 100644 --- a/changelog.d/2764.added.md +++ b/changelog.d/2764.added.md @@ -1 +1 @@ -Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus `gov.simulation.trading_allowance_in_input_profits` (£1,000 from 2017-18, which reforms to the allowance do not move and which is not uprated), so a raise to any level relieves them by the allowance's excess over £1,000, up to their profit, and a cut below £1,000 leaves their profit as reported. A non-claimant gets no allowance at any level, on trade receipts or on income not otherwise charged. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. +Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the data were measured (a dataset's first year, or the year simulated without one), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted, and they keep the allowance, giving up current losses, at any level above nil. A cut below that allowance adds to their profit what `gov.simulation.trading_allowance_claimant_expense_share` sets, by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml index d92597f6e4..acb8146986 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml @@ -1,4 +1,4 @@ -description: Amount of trading allowance, which relieves trading receipts in place of deducting actual expenses. +description: Amount of trading allowance, which relieves trading receipts in place of deducting actual expenses. For people the input data mark as claimants (trading_allowance_claim_status), whose profit already deducts the allowance legislated for the data year, a cut below that allowance adds to their taxable profit only what gov.simulation.trading_allowance_claimant_expense_share sets, by default nothing. values: 2005-04-01: value: 0 diff --git a/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml b/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml new file mode 100644 index 0000000000..4fbd2c2d8b --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml @@ -0,0 +1,19 @@ +description: Actual expenses and capital allowances of a trading allowance claimant, as a share of the allowance their input profit already deducts. It sets how much a cut below that allowance adds to the taxable profit of people the input data mark as claimants (trading_allowance_claim_status). A claimant keeps claiming while the allowance is at least these expenses, and otherwise deducts them instead, so a cut adds the input allowance less the larger of the new allowance and these expenses. The allowance at or above the input allowance is unaffected. +# A claimant's expenses and capital allowances are below the allowance they +# claim, or they would deduct them instead, but no survey records them. With +# a share of 1, the default, they equal it, so a cut leaves a claimant's +# taxable profit as reported: the lower bound of its yield. With 0 they have +# none, so every claimant keeps claiming and a cut adds its full amount: the +# upper bound. 1 is a convention, not an estimate, kept so that the model +# books no yield it cannot support (PolicyEngine/policyengine-uk#2764). +values: + 2005-04-01: 1 +metadata: + unit: /1 + period: year + label: Trading allowance claimant expenses as a share of the input allowance + reference: + - title: Income Tax (Trading and Other Income) Act 2005 s. 783AI (partial relief) + href: https://www.legislation.gov.uk/ukpga/2005/5/section/783AI + - title: Income Tax (Trading and Other Income) Act 2005 s. 783AM (election for partial relief) + href: https://www.legislation.gov.uk/ukpga/2005/5/section/783AM diff --git a/policyengine_uk/parameters/gov/simulation/trading_allowance_in_input_profits.yaml b/policyengine_uk/parameters/gov/simulation/trading_allowance_in_input_profits.yaml deleted file mode 100644 index 950ce8b9cc..0000000000 --- a/policyengine_uk/parameters/gov/simulation/trading_allowance_in_input_profits.yaml +++ /dev/null @@ -1,26 +0,0 @@ -description: Trading allowance already deducted in the self-employment profit of a person whose trading_allowance_claim_status is claimant. Their receipts are that profit plus this amount, so the trading allowance relieves them by its excess over this amount, up to their profit. This describes the input data, not policy, so reforms to the trading allowance do not move it and it is not uprated. Change it only for input data measured under a different allowance, such as data for 2027-28 or later. -# Survey data from 2017-18 to 2026-27 were measured under the £1,000 -# allowance, which the model keeps here past the April 2027 raise to £3,000 -# (gov.hmrc.income_tax.allowances.trading_allowance): a claimant flagged in -# such data and projected to a later year still deducted £1,000. Before -# 2017-18 there was no allowance to deduct. -values: - 2005-04-01: - value: 0 - reference: - - title: Finance (No. 2) Act 2017 Sch. 3 para. 13 (allowance has effect from 2017-18) - href: https://www.legislation.gov.uk/ukpga/2017/32/schedule/3/paragraph/13 - 2017-04-06: - value: 1_000 - reference: - - title: Income Tax (Trading and Other Income) Act 2005 s. 783AD - href: https://www.legislation.gov.uk/ukpga/2005/5/section/783AD - - title: Finance (No. 2) Act 2017 Sch. 3 - href: https://www.legislation.gov.uk/ukpga/2017/32/schedule/3 -metadata: - label: Trading allowance deducted in input profits - period: year - unit: currency-GBP - reference: - - title: Income Tax (Trading and Other Income) Act 2005 s. 783AI (partial relief) - href: https://www.legislation.gov.uk/ukpga/2005/5/section/783AI diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml index dad4810334..d5dcef711c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml @@ -1,15 +1,21 @@ -# trading_allowance_claim_status says whether a person's input profit already -# deducts the trading allowance, as on a return claiming partial relief -# (ITTOIA 2005 s. 783AI). The allowance deducted in the input data is -# gov.simulation.trading_allowance_in_input_profits, £1,000 from 2017-18, -# which reforms to the trading allowance do not move. -# - Claimant: receipts are profit + £1,000, so the deduction at allowance A is -# min(profit, A - £1,000), and nil at or below £1,000. -# - Non-claimant: actual expenses above £1,000; no deduction at any A. +# trading_allowance_claim_status says which receipts lie behind a person's +# input profit (ITTOIA 2005 Part 6A). The input allowance is the allowance +# legislated for the year the data were measured: a dataset's first year, or +# here, without a dataset, the year simulated. Reforms do not move it. +# - Claimant: the profit is receipts less the input allowance, as under +# partial relief (s. 783AI), so receipts are profit + input allowance and +# the deduction at allowance A is min(profit, A - input allowance). Partial +# relief replaces capital allowances, so a claimant's are not deducted. +# Their claim was the election (s. 783AM), so they keep the allowance, and +# give up current losses, at any A above nil. +# - Non-claimant: no election for partial relief; full relief needs none +# (s. 783AE) and applies where supplied receipts, or casual income alone +# with no profit, are within the allowance. # - Unknown (the default): unchanged. # The allowance is £1,000 in 2026-27 and £3,000 from 2027-28 (Budget 2026, -# paragraph 6.254). Personal allowance £12,570, basic rate 20%, Class 4 main -# rate 6% above the £12,570 lower profits limit. +# paragraph 6.254), so the input allowance is £1,000 in the 2026 cases and +# £3,000 in the 2027 ones. Personal allowance £12,570, basic rate 20%, +# Class 4 main rate 6% above the £12,570 lower profits limit. - name: A claimant at the input allowance keeps their profit as reported. period: 2026 @@ -20,6 +26,8 @@ output: trading_allowance_deduction: 0 taxable_self_employment_income: 2_500 + # Their claim is the election, though it relieves nothing more. + uses_trading_allowance: true - name: A claimant's profit below the allowance already deducts it. period: 2026 @@ -33,10 +41,21 @@ trading_allowance_deduction: 0 taxable_self_employment_income: 600 -- name: The raise to £3,000 gives a claimant £2,000 more. +- name: A 2027-28 claimant's profit already deducts the £3,000 allowance. period: 2027 + absolute_error_margin: 0 + input: + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 + +- name: A raise to £3,000 gives a claimant £2,000 more. + period: 2026 absolute_error_margin: 0.01 input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 employment_income: 30_000 self_employment_income: 2_500 trading_allowance_claim_status: CLAIMANT @@ -48,9 +67,10 @@ income_tax: 3_586 - name: A claimant whose receipts are within £3,000 is fully relieved. - period: 2027 + period: 2026 absolute_error_margin: 0 input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 self_employment_income: 2_000 trading_allowance_claim_status: CLAIMANT output: @@ -59,7 +79,7 @@ taxable_self_employment_income: 0 - name: A raise to £5,000 gives a claimant £4,000 more. - period: 2027 + period: 2026 absolute_error_margin: 0 input: gov.hmrc.income_tax.allowances.trading_allowance: 5_000 @@ -71,7 +91,7 @@ taxable_self_employment_income: 2_000 - name: A claimant whose receipts are within £5,000 is fully relieved. - period: 2027 + period: 2026 absolute_error_margin: 0 input: gov.hmrc.income_tax.allowances.trading_allowance: 5_000 @@ -82,6 +102,18 @@ trading_allowance_deduction: 3_500 taxable_self_employment_income: 0 +- name: A raise from 2027-28's £3,000 to £5,000 gives a claimant £2,000 more. + period: 2027 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 5_000 + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts 2,500 + 3,000 = 5,500, less the £5,000 allowance. + trading_allowance_deduction: 2_000 + taxable_self_employment_income: 500 + - name: A cut to £500 leaves a claimant's profit as reported. period: 2026 absolute_error_margin: 0 @@ -100,15 +132,41 @@ household: members: [claimant, unknown] output: - # The claimant's receipts are £3,500 and their actual expenses are below - # £1,000 but unknown. Still claiming, the Act would charge - # 3,500 - 500 = 3,000; with expenses of £1,000, 2,500. The model takes - # the lower figure. The unknown-status £800 profit is above £500 and - # loses its full relief. + # The claimant's receipts are £3,500 and their expenses are below + # £1,000 but unknown. By default + # (gov.simulation.trading_allowance_claimant_expense_share = 1) they are + # taken as £1,000, so a cut adds nothing: the lower bound. The + # unknown-status £800 profit is above £500 and loses its full relief. # [claimant, unknown] trading_allowance_deduction: [0, 0] taxable_self_employment_income: [2_500, 800] +- name: With no expenses, a cut to £500 adds £500 to a claimant's profit. + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 500 + gov.simulation.trading_allowance_claimant_expense_share: 0 + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + # They keep claiming: 3,500 - 500 = 3,000, the upper bound. + trading_allowance_deduction: -500 + taxable_self_employment_income: 3_000 + +- name: With £500 of expenses, a cut to £300 adds £500 to a claimant's profit. + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 300 + gov.simulation.trading_allowance_claimant_expense_share: 0.5 + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + # Expenses of 0.5 x 1,000 = 500 beat a £300 allowance: 3,500 - 500. + trading_allowance_deduction: -500 + taxable_self_employment_income: 3_000 + - name: Abolishing the allowance leaves a claimant's profit as reported. period: 2026 absolute_error_margin: 0 @@ -130,22 +188,40 @@ # [claimant, unknown] trading_allowance_deduction: [0, 0] taxable_self_employment_income: [2_500, 800] + uses_trading_allowance: [false, false] -- name: Input data measured under the £3,000 allowance gain nothing from it. - period: 2027 +- name: A claimant's capital allowances are not deducted. + period: 2026 absolute_error_margin: 0 input: - gov.simulation.trading_allowance_in_input_profits: 3_000 self_employment_income: 2_500 + capital_allowances: 500 trading_allowance_claim_status: CLAIMANT output: - trading_allowance_deduction: 0 + # Partial relief replaces them (s. 783AI): receipts 3,500 less £1,000. + # The deduction gives back the £500 deducted separately. + trading_allowance_deduction: -500 taxable_self_employment_income: 2_500 + ni_class_4_profits_before_losses: 2_500 + +- name: Under a raise to £3,000 a claimant's capital allowances are still not deducted. + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 + self_employment_income: 2_500 + capital_allowances: 500 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts 3,500 less £3,000, so the raise gains the full £2,000. + trading_allowance_deduction: 1_500 + taxable_self_employment_income: 500 - name: A claimant's status takes precedence over supplied receipts. - period: 2027 + period: 2026 absolute_error_margin: 0 input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 self_employment_gross_receipts: 6_000 self_employment_income: 2_500 trading_allowance_claim_status: CLAIMANT @@ -154,6 +230,30 @@ trading_allowance_deduction: 2_000 taxable_self_employment_income: 500 +- name: A claimant with no profit is read as unknown. + period: 2026 + absolute_error_margin: 0 + input: + miscellaneous_income: 2_000 + income_not_otherwise_charged: 2_000 + trading_allowance_claim_status: CLAIMANT + output: + # No trade receipts are invented, so the casual income has the whole + # £1,000 allowance. + miscellaneous_income_trading_allowance_deduction: 1_000 + taxable_miscellaneous_income: 1_000 + +- name: A no-profit claimant's casual income within £3,000 is fully relieved. + period: 2027 + absolute_error_margin: 0 + input: + miscellaneous_income: 2_800 + income_not_otherwise_charged: 2_800 + trading_allowance_claim_status: CLAIMANT + output: + miscellaneous_income_trading_allowance_deduction: 2_800 + taxable_miscellaneous_income: 0 + - name: A non-claimant's profit below the allowance is taxed. period: 2027 absolute_error_margin: 0 @@ -161,8 +261,8 @@ self_employment_income: 600 trading_allowance_claim_status: NON_CLAIMANT output: - # Actual expenses above £1,000 put receipts above £1,600. Unknown status - # would relieve the £600 in full. + # Expenses above the allowance put receipts above it, so full relief + # cannot apply. Unknown status would relieve the £600 in full. trading_allowance_deduction: 0 taxable_self_employment_income: 600 @@ -189,7 +289,7 @@ trading_allowance_deduction: [0, 4_000] taxable_self_employment_income: [2_500, 0] -- name: A non-claimant's status takes precedence over supplied receipts. +- name: A non-claimant's supplied receipts above the allowance give no switching gain. period: 2027 absolute_error_margin: 0 input: @@ -198,10 +298,71 @@ trading_allowance_claim_status: NON_CLAIMANT output: # With £1,500 of expenses, electing for the £3,000 allowance would - # charge £1,000. That switch is a change in behaviour. + # charge £1,000. That election is a change in behaviour. trading_allowance_deduction: 0 taxable_self_employment_income: 2_500 +- name: A non-claimant's receipts within the allowance are fully relieved. + period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_gross_receipts: 2_800 + self_employment_income: 600 + trading_allowance_claim_status: NON_CLAIMANT + output: + # Full relief needs no election (s. 783AE). + trading_allowance_deduction: 600 + taxable_self_employment_income: 0 + # (30,000 - 12,570) x 20% + income_tax: 3_486 + +- name: A non-claimant's receipts and casual income within the allowance are both relieved. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 2_000 + self_employment_income: 600 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + trading_allowance_claim_status: NON_CLAIMANT + output: + # Relevant income 2,000 + 900 = 2,900 (ss. 783AF and 783AG). + trading_allowance_deduction: 600 + miscellaneous_income_trading_allowance_deduction: 900 + taxable_miscellaneous_income: 0 + +- name: A non-claimant's receipts and casual income above the allowance get nothing. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 2_500 + self_employment_income: 600 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + trading_allowance_claim_status: NON_CLAIMANT + output: + # Relevant income 3,400 needs partial relief, which they do not elect + # (s. 783AM). + trading_allowance_deduction: 0 + miscellaneous_income_trading_allowance_deduction: 0 + taxable_miscellaneous_income: 900 + uses_trading_allowance: false + +- name: A non-claimant with no trade has casual income within the allowance relieved. + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + miscellaneous_income: 800 + income_not_otherwise_charged: 800 + trading_allowance_claim_status: NON_CLAIMANT + output: + miscellaneous_income_trading_allowance_deduction: 800 + taxable_miscellaneous_income: 0 + # (30,000 - 12,570) x 20% + income_tax: 3_486 + - name: Unknown status still relieves a profit within the allowance. period: 2027 absolute_error_margin: 0 @@ -224,9 +385,10 @@ taxable_self_employment_income: 2_000 - name: Class 4 reads a claimant's deduction. - period: 2027 + period: 2026 absolute_error_margin: 0.01 input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 self_employment_income: 20_000 trading_allowance_claim_status: CLAIMANT output: @@ -238,9 +400,10 @@ income_tax: 1_086 - name: Class 4 charges a non-claimant's whole profit. - period: 2027 + period: 2026 absolute_error_margin: 0.01 input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 self_employment_income: 20_000 trading_allowance_claim_status: NON_CLAIMANT output: @@ -251,8 +414,8 @@ # (20,000 - 12,570) x 20% income_tax: 1_486 -- name: A claimant's £2,000 more beats a smaller current loss. - period: 2027 +- name: A claimant gives up a current loss at the input allowance. + period: 2026 absolute_error_margin: 0.01 input: employment_income: 30_000 @@ -260,30 +423,62 @@ trading_loss: 1_500 trading_allowance_claim_status: CLAIMANT output: - # The allowance saves £2,000 against a £1,500 loss, and makes the loss - # nil (ss. 783AF and 783AI). - trading_allowance_deduction: 2_000 + # Their claim made the loss nil (ss. 783AF and 783AI). + trading_allowance_deduction: 0 uses_trading_allowance: true trade_loss_relief_against_general_income: 0 ni_class_4_trading_loss: 0 - # (30,000 + 500 - 12,570) x 20% + # (30,000 + 2,500 - 12,570) x 20% + income_tax: 3_986 + +- name: A raise to £3,000 gives a claimant with a current loss its full £2,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 1_500 + trading_allowance_claim_status: CLAIMANT + output: + trading_allowance_deduction: 2_000 + uses_trading_allowance: true + trade_loss_relief_against_general_income: 0 + # (30,000 + 500 - 12,570) x 20%, £400 less than at £1,000. income_tax: 3_586 -- name: A current loss larger than a claimant's £2,000 is claimed instead. - period: 2027 +- name: A claimant keeps the allowance against a current loss larger than its saving. + period: 2026 absolute_error_margin: 0.01 input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 employment_income: 30_000 self_employment_income: 2_500 trading_loss: 2_500 trading_allowance_claim_status: CLAIMANT output: - trading_allowance_deduction: 0 + # They chose the allowance over this loss at £1,000, and a raise only + # makes it better. + trading_allowance_deduction: 2_000 + uses_trading_allowance: true + trade_loss_relief_against_general_income: 0 + ni_class_4_trading_loss: 0 + income_tax: 3_586 + +- name: Abolishing the allowance lets a claimant claim a current loss. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 0 + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 1_500 + trading_allowance_claim_status: CLAIMANT + output: uses_trading_allowance: false - trade_loss_relief_against_general_income: 2_500 - ni_class_4_trading_loss: 2_500 - # (30,000 + 2,500 - 2,500 - 12,570) x 20% - income_tax: 3_486 + trade_loss_relief_against_general_income: 1_500 + # (30,000 + 2,500 - 1,500 - 12,570) x 20% + income_tax: 3_686 - name: A non-claimant claims a current loss the allowance would have taken. period: 2027 @@ -301,25 +496,44 @@ # (30,000 + 600 - 500 - 12,570) x 20% income_tax: 3_506 -- name: A claimant's receipts and casual income within £3,000 are both relieved. +- name: A non-claimant elects out of full relief to claim a larger current loss. period: 2027 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + self_employment_gross_receipts: 2_800 + self_employment_income: 600 + trading_loss: 1_000 + trading_allowance_claim_status: NON_CLAIMANT + output: + # Full relief saves £600 against a £1,000 loss (s. 783AL). + trading_allowance_deduction: 0 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 1_000 + # (30,000 + 600 - 1,000 - 12,570) x 20% + income_tax: 3_406 + +- name: A claimant's receipts and casual income within £3,000 are both relieved. + period: 2026 absolute_error_margin: 0 input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 self_employment_income: 500 miscellaneous_income: 900 income_not_otherwise_charged: 900 trading_allowance_claim_status: CLAIMANT output: - # Receipts of £1,500 and casual income of £900 (s. 783AF and 783AG). + # Receipts of £1,500 and casual income of £900 (ss. 783AF and 783AG). trading_allowance_deduction: 500 taxable_self_employment_income: 0 miscellaneous_income_trading_allowance_deduction: 900 taxable_miscellaneous_income: 0 - name: Casual income gets what a claimant's receipts leave of £3,000. - period: 2027 + period: 2026 absolute_error_margin: 0 input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 self_employment_income: 1_800 miscellaneous_income: 900 income_not_otherwise_charged: 900 @@ -345,18 +559,3 @@ taxable_self_employment_income: 500 miscellaneous_income_trading_allowance_deduction: 0 taxable_miscellaneous_income: 900 - -- name: A non-claimant's casual income gets no allowance. - period: 2027 - absolute_error_margin: 0 - input: - self_employment_income: 500 - miscellaneous_income: 900 - income_not_otherwise_charged: 900 - trading_allowance_claim_status: NON_CLAIMANT - output: - # Not electing for partial relief covers all relevant income (s. 783AM). - trading_allowance_deduction: 0 - miscellaneous_income_trading_allowance_deduction: 0 - taxable_miscellaneous_income: 900 - uses_trading_allowance: false diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py new file mode 100644 index 0000000000..2319651d92 --- /dev/null +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py @@ -0,0 +1,133 @@ +"""The trading allowance claim status reads the allowance of the year the +input data were measured (PolicyEngine/policyengine-uk#2764). + +A claimant's profit already deducts the allowance legislated for that year: +a dataset's first year, which Simulation projects to later years, or +without a dataset the year simulated. These tests build small datasets, so +the data year and the simulated year differ, as they do for survey data +projected past the April 2027 raise to £3,000 (Budget 2026, paragraph +6.254). +""" + +import pandas as pd +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.data import UKSingleYearDataset + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +STATUSES = ["CLAIMANT", "NON_CLAIMANT", "UNKNOWN"] +ALLOWANCE = "gov.hmrc.income_tax.allowances.trading_allowance" + + +def traders(year, *, loss=0.0): + """A claimant, a non-claimant and a trader of unknown status, measured in + ``year``, each with £30,000 of pay and £5,000 of profit.""" + count = len(STATUSES) + person = pd.DataFrame( + { + "person_id": range(1, count + 1), + "person_benunit_id": range(1, count + 1), + "person_household_id": range(1, count + 1), + "age": [40] * count, + "employment_income": [30_000.0] * count, + "self_employment_income": [5_000.0] * count, + "trading_loss": [loss] * count, + "trading_allowance_claim_status": STATUSES, + } + ) + household = pd.DataFrame( + { + "household_id": range(1, count + 1), + "region": ["LONDON"] * count, + "tenure_type": ["OWNED_OUTRIGHT"] * count, + "council_tax": [0.0] * count, + "rent": [0.0] * count, + "household_weight": [1.0] * count, + } + ) + return UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": range(1, count + 1)}), + household=household, + fiscal_year=year, + ) + + +def deduction(simulation, year): + return simulation.calculate("trading_allowance_deduction", year).tolist() + + +def test_the_budget_raise_reaches_claimants_measured_at_1000(): + simulation = Simulation(dataset=traders(2025)) + # 2026-27: £1,000, which the claimant's profit already deducts. + assert deduction(simulation, 2026) == [0, 0, 0] + # 2027-28: £3,000 on the claimant's receipts of profit + £1,000. The + # non-claimant elects nothing, and the unknown profit is above £3,000. + assert deduction(simulation, 2027) == pytest.approx([2_000, 0, 0]) + + +def test_data_measured_at_3000_gain_nothing_from_it(): + simulation = Simulation(dataset=traders(2027)) + for year in (2027, 2028): + assert deduction(simulation, year) == [0, 0, 0] + + +def test_a_reform_of_the_data_year_leaves_the_input_allowance(): + reform = {ALLOWANCE: {str(year): 5_000 for year in range(2025, 2028)}} + simulation = Simulation(dataset=traders(2025), reform=reform) + # The profit still deducts the £1,000 legislated for 2025-26. + assert deduction(simulation, 2026)[0] == pytest.approx(4_000) + + +def test_a_raise_never_withdraws_a_claimants_loss_relief(): + kept = {ALLOWANCE: {str(year): 1_000 for year in range(2027, 2029)}} + law = Simulation(dataset=traders(2025, loss=5_000)) + at_1000 = Simulation(dataset=traders(2025, loss=5_000), reform=kept) + # The claimant's claim gave the loss up at £1,000 and still does at + # £3,000, so the raise only adds the £2,000 deduction. + tax = law.calculate("income_tax", 2027)[0] + assert tax == pytest.approx(at_1000.calculate("income_tax", 2027)[0] - 400, abs=1) + + +def situation(person): + return { + "people": {"person": person}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + + +def test_the_status_carries_forward_from_the_year_supplied(): + simulation = Simulation( + situation=situation( + { + "age": {2025: 40}, + "self_employment_income": {2025: 2_500}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + ) + ) + # Simulation.calculate returns enum names. + status = simulation.calculate("trading_allowance_claim_status", 2027) + assert status.tolist() == ["CLAIMANT"] + + +def test_a_raise_leaves_a_situation_claimants_carried_loss(): + # Without a dataset the data year is the year simulated, so this + # claimant's 2027 profit already deducts £3,000, and keeping £1,000 is a + # cut. Either way they keep the allowance and give up the loss. + person = { + "age": {2025: 40}, + "employment_income": {2025: 30_000}, + "self_employment_income": {2025: 5_000}, + "trading_loss": {2025: 5_000}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + kept = {ALLOWANCE: {"2027": 1_000}} + law = Simulation(situation=situation(person)) + at_1000 = Simulation(situation=situation(person), reform=kept) + assert law.calculate("income_tax", 2027)[0] == pytest.approx( + at_1000.calculate("income_tax", 2027)[0], abs=0.01 + ) diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py index 2aaaaf67ec..c6f5f2cbd4 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py @@ -2,28 +2,29 @@ Chapter 1). Survey data hold profit, not receipts. The claim status says which receipts -lie behind it. A claimant's input profit already deducts the allowance in -force when it was measured (gov.simulation.trading_allowance_in_input_profits, -£1,000), so their receipts are that profit plus £1,000. A non-claimant deducts -actual expenses above £1,000. Unknown, the default, keeps the rules for -supplied or missing receipts. +lie behind a positive profit. A claimant's profit already deducts the +allowance legislated for the year the data were measured (here, without a +dataset, the year simulated: £1,000 in 2026-27), so their receipts are that +profit plus £1,000. A non-claimant did not elect for partial relief. Unknown, +the default, keeps the rules for supplied or missing receipts. + +The reference below is written from the Act's options rather than from the +model's formula: full relief applies without an election whenever relevant +income is within the allowance (s. 783AE); partial relief needs an election +(s. 783AM), which a non-claimant has not made and a claimant has; ordinary +treatment deducts actual expenses and capital allowances, which for a +claimant partial relief replaced and are taken as a share of the input +allowance (gov.simulation.trading_allowance_claimant_expense_share). Invariants, each checked over many generated people in one reusable -simulation per allowance level, from abolition to well above the £3,000 of -2027-28: - -1. Every status matches a reference written from the Act. A claimant's - taxable trade profit is the lower of ordinary treatment (the input profit, - with expenses read as £1,000) and the allowance on receipts of profit plus - £1,000, whatever receipts are supplied. Their income not otherwise charged - gets what those receipts leave of the allowance. A non-claimant gets - neither deduction. Unknown status gives what it gave before the status - existed. A current loss larger than the allowance's saving is claimed - instead. -2. A claimant's two deductions never exceed the allowance's excess over - £1,000, so a cut to £1,000 or below leaves their profit as reported. +simulation per allowance level and expense share: + +1. Every status matches the reference: taxable trade profit, the Chapter 8 + deduction, Class 4 profits and whether the person takes the allowance. +2. A claimant's deductions never exceed the allowance's excess over £1,000, + and with the default share a cut leaves their profit as reported. 3. A larger allowance never raises taxable trade profit, for any status. -4. Class 4 reads the same deduction as income tax. +4. A smaller expense share never lowers a claimant's taxable trade profit. """ import numpy as np @@ -35,16 +36,22 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") -YEAR = 2027 +YEAR = 2026 +# s. 783AD, the allowance for 2026-27, the year these situations are measured. INPUT_ALLOWANCE = 1_000 -ALLOWANCES = [0, 500, 1_000, 2_000, 3_000, 5_000, 10_000] +ALLOWANCES = [0, 300, 500, 1_000, 2_000, 3_000, 5_000, 10_000] +# (allowance, claimant expense share): every allowance at the default share, +# and cuts at lower shares. +SCENARIOS = [(allowance, 1.0) for allowance in ALLOWANCES] + [ + (allowance, share) for allowance in (0, 300, 500) for share in (0.0, 0.5) +] STATUSES = ["UNKNOWN", "CLAIMANT", "NON_CLAIMANT"] PEOPLE = 30 amount = st.one_of( st.floats(min_value=0, max_value=12_000), st.floats(min_value=0, max_value=100_000), - st.sampled_from([0.0, 500.0, 999.99, 1_000.0, 2_000.0, 3_000.0, 4_000.0]), + st.sampled_from([0.0, 300.0, 500.0, 999.99, 1_000.0, 2_000.0, 4_000.0]), ) case = st.tuples( st.sampled_from(STATUSES), @@ -53,7 +60,8 @@ st.sampled_from(["unknown", "consistent", "below_profit"]), # receipts st.sampled_from([0.0, 0.0, 150.0, 2_500.0]), # capital allowances st.sampled_from([0.0, 0.0, 0.0, 300.0, 2_000.0]), # losses brought forward - st.sampled_from([0.0, 0.0, 400.0, 900.0, 5_000.0]), # Chapter 8 income + st.one_of(amount, st.floats(min_value=-2_000, max_value=0)), # misc income + st.sampled_from([0.0, 0.0, 0.5, 1.0, 1.5]), # Chapter 8 share of it st.sampled_from([0.0, 0.0, 0.0, 0.0, 300.0, 3_000.0]), # current loss ) cases = st.lists(case, min_size=PEOPLE, max_size=PEOPLE) @@ -72,22 +80,33 @@ def sims(): }, } return { - allowance: Simulation( + (allowance, share): Simulation( situation=situation, reform={ "gov.hmrc.income_tax.allowances.trading_allowance": { str(YEAR): allowance - } + }, + "gov.simulation.trading_allowance_claimant_expense_share": { + str(YEAR): share + }, }, ) - for allowance in ALLOWANCES + for allowance, share in SCENARIOS } def run(sim, drawn): - status, profit, expenses, mode, capital, brought_forward, chapter_8, loss = ( - np.array(x) for x in zip(*drawn) - ) + ( + status, + profit, + expenses, + mode, + capital, + brought_forward, + misc, + chapter_8_share, + loss, + ) = (np.array(x) for x in zip(*drawn)) receipts = np.select( [mode == "consistent", mode == "below_profit"], [np.maximum(profit, 0) + expenses, 0.5 * np.maximum(profit, 0)], @@ -98,8 +117,12 @@ def run(sim, drawn): sim.set_input("self_employment_gross_receipts", YEAR, receipts) sim.set_input("capital_allowances", YEAR, capital) sim.set_input("loss_relief", YEAR, brought_forward) - sim.set_input("miscellaneous_income", YEAR, chapter_8) - sim.set_input("income_not_otherwise_charged", YEAR, chapter_8) + sim.set_input("miscellaneous_income", YEAR, misc) + sim.set_input( + "income_not_otherwise_charged", + YEAR, + chapter_8_share * np.maximum(misc, 0), + ) sim.set_input("trading_loss", YEAR, loss) sim.reset_calculations() @@ -113,63 +136,83 @@ def stored(name): receipts=stored("self_employment_gross_receipts"), capital=stored("capital_allowances"), brought_forward=stored("loss_relief"), + misc=stored("miscellaneous_income"), chapter_8=stored("income_not_otherwise_charged"), loss=stored("trading_loss"), trade_relief=stored("trading_allowance_deduction"), chapter_8_relief=stored("miscellaneous_income_trading_allowance_deduction"), taxable=stored("taxable_self_employment_income"), class_4_profits=stored("ni_class_4_profits_before_losses"), + uses=sim.calculate("uses_trading_allowance", YEAR), ) -def reference(r, allowance): - """Taxable trade profit and the Chapter 8 deduction, from the Act. - - Receipts within the allowance are fully relieved (s. 783AF). Above it the - person takes the better of actual deductions and the allowance (s. - 783AI), and Chapter 8 income gets the part of the allowance the trade - receipts leave (s. 783AK, trades first). Without receipts, a profit within - the allowance is relieved in full and a larger one is taxed as reported. - """ +def reference(r, allowance, share): + """Taxable trade profit, Class 4 profits, the Chapter 8 deduction and + whether the person takes the allowance, from the Act's options.""" profit, capital = r["profit"], r["capital"] - claimant = r["status"] == "CLAIMANT" + brought_forward, loss = r["brought_forward"], r["loss"] + # Chapter 8 income is part of miscellaneous income. + chapter_8 = np.minimum(np.maximum(r["chapter_8"], 0), np.maximum(r["misc"], 0)) + trading = profit > 0 + claimant = trading & (r["status"] == "CLAIMANT") non_claimant = r["status"] == "NON_CLAIMANT" supplied = (r["receipts"] > 0) & (r["receipts"] >= profit) - receipts = np.where( - claimant, np.maximum(profit, 0) + INPUT_ALLOWANCE, r["receipts"] - ) - known = claimant | supplied - # A claimant's input profit is ordinary treatment with expenses of - # £1,000: receipts less the input allowance. - ordinary = profit - capital - with_allowance = np.where( - known, - np.where( - receipts <= allowance, - 0, - np.minimum(ordinary, receipts - allowance), - ), - np.where(profit <= allowance, 0, ordinary), - ) - with_allowance = np.where(non_claimant, ordinary, with_allowance) - trade_saving = np.maximum(ordinary - with_allowance, 0) - # The trade receipts that take up the allowance first. - used = np.where(known, receipts, np.minimum(np.maximum(profit, 0), allowance)) - chapter_8_relief = np.where( - non_claimant, - 0, - np.minimum(r["chapter_8"], np.maximum(allowance - used, 0)), + # Trade receipts. A claimant's profit is receipts less the input + # allowance. Otherwise supplied receipts count; without them, no profit + # means no receipts, a profit within the allowance means receipts equal + # to it, and a non-claimant's profit (their expenses exceed the input + # allowance) or a larger profit means receipts above any allowance. + receipts = np.select( + [claimant, supplied, ~trading, non_claimant | (profit > allowance)], + [profit + INPUT_ALLOWANCE, r["receipts"], 0.0, np.inf], + profit, ) - # The election: a larger current loss is claimed instead (s. 783AL and - # s. 783AM), and then neither deduction applies. + # Ordinary treatment deducts actual expenses and capital allowances; a + # claimant's are the share of the input allowance. + ordinary = np.where(claimant, receipts - share * INPUT_ALLOWANCE, profit - capital) + # Full relief (no election) or partial relief, trades first (s. 783AK). + full = receipts + chapter_8 <= allowance + with_allowance = np.where(full, 0, np.maximum(receipts - allowance, 0)) + chapter_8_relief = np.minimum(chapter_8, np.maximum(allowance - receipts, 0)) + # Partial relief needs an election, which a non-claimant has not made. + available = full | ~non_claimant + trade = np.where(available, np.minimum(with_allowance, ordinary), ordinary) + chapter_8_relief = np.where(available, chapter_8_relief, 0) + # A claimant's claim is their election. Anyone else claims a current + # loss larger than the allowance's saving instead (ss. 783AL, 783AM). saving = ( - np.minimum(trade_saving, np.maximum(ordinary - r["brought_forward"], 0)) + np.maximum(ordinary - brought_forward, 0) + - np.maximum(trade - brought_forward, 0) + chapter_8_relief ) - claim_loss = r["loss"] > saving - taxable_before_losses = np.where(claim_loss, ordinary, ordinary - trade_saving) - taxable = np.maximum(taxable_before_losses - r["brought_forward"], 0) - return taxable, np.where(claim_loss, 0, chapter_8_relief) + claims_loss = ~claimant & (loss > saving) + trade = np.where(claims_loss, ordinary, trade) + chapter_8_relief = np.where(claims_loss, 0, chapter_8_relief) + takes_allowance = np.where( + claimant, + allowance > 0, + ~claims_loss & ((ordinary - trade > 0) | (chapter_8_relief > 0)), + ) + # The model sums in float32, so where the reference compares a sum with + # a threshold it may land on the other side within rounding: a + # non-claimant's relevant income against the allowance, a supplied loss + # against the saving, and, for whether the allowance is taken, its + # advantage on the trade against nil. + margin = tolerance(r["receipts"], profit, chapter_8, allowance) + finite = np.isfinite(receipts) + near_full = non_claimant & finite + near_full &= np.abs(np.where(finite, receipts, 0) + chapter_8 - allowance) <= margin + near_loss = (loss > 0) & (np.abs(loss - saving) <= margin) + advantage = np.where(np.isfinite(with_allowance), ordinary - with_allowance, -1) + return dict( + taxable=np.maximum(trade - brought_forward, 0), + class_4_profits=np.maximum(trade, 0), + chapter_8_relief=chapter_8_relief, + uses=takes_allowance, + fragile=near_full | near_loss, + fragile_uses=near_full | near_loss | (np.abs(advantage) <= margin), + ) def tolerance(*values): @@ -186,15 +229,20 @@ def tolerance(*values): @SETTINGS @given(cases) -def test_every_status_matches_the_act_at_every_allowance(sims, drawn): - for allowance, sim in sims.items(): +def test_every_status_matches_the_act(sims, drawn): + for (allowance, share), sim in sims.items(): r = run(sim, drawn) - taxable, chapter_8_relief = reference(r, allowance) - margin = tolerance(r["receipts"], r["profit"], allowance) - assert (np.abs(r["taxable"] - taxable) <= margin).all(), allowance - assert (np.abs(r["chapter_8_relief"] - chapter_8_relief) <= margin).all(), ( - allowance - ) + expected = reference(r, allowance, share) + margin = tolerance(r["receipts"], r["profit"], r["chapter_8"], allowance) + compared = ~expected["fragile"] + for name in ("taxable", "class_4_profits", "chapter_8_relief"): + assert (np.abs(r[name] - expected[name]) <= margin)[compared].all(), ( + name, + allowance, + share, + ) + compared = ~expected["fragile_uses"] + assert (r["uses"] == expected["uses"])[compared].all(), (allowance, share) @SETTINGS @@ -202,14 +250,15 @@ def test_every_status_matches_the_act_at_every_allowance(sims, drawn): def test_a_claimant_gets_only_the_allowances_excess_over_the_input_allowance( sims, drawn ): - for allowance, sim in sims.items(): + for (allowance, share), sim in sims.items(): r = run(sim, drawn) - claimant = r["status"] == "CLAIMANT" - relief = r["trade_relief"] + r["chapter_8_relief"] + claimant = (r["status"] == "CLAIMANT") & (r["profit"] > 0) + # The deduction gives back the capital allowances deducted separately. + relief = r["trade_relief"] + r["capital"] + r["chapter_8_relief"] excess = max(allowance - INPUT_ALLOWANCE, 0) assert (relief[claimant] <= excess + tolerance(allowance)).all(), allowance - if allowance <= INPUT_ALLOWANCE: - reported = np.maximum(r["profit"] - r["capital"] - r["brought_forward"], 0) + if allowance <= INPUT_ALLOWANCE and share == 1: + reported = np.maximum(r["profit"] - r["brought_forward"], 0) assert ( np.abs(r["taxable"] - reported)[claimant] <= tolerance(r["profit"])[claimant] @@ -219,17 +268,19 @@ def test_a_claimant_gets_only_the_allowances_excess_over_the_input_allowance( @SETTINGS @given(cases) def test_a_larger_allowance_never_raises_taxable_profit(sims, drawn): - taxable = [run(sims[allowance], drawn)["taxable"] for allowance in ALLOWANCES] - for smaller, larger in zip(taxable, taxable[1:]): - assert (larger <= smaller + tolerance(smaller)).all() + for share in (1.0, 0.5, 0.0): + levels = sorted(a for a, s in SCENARIOS if s == share) + taxable = [run(sims[(a, share)], drawn)["taxable"] for a in levels] + for smaller, larger in zip(taxable, taxable[1:]): + assert (larger <= smaller + tolerance(smaller)).all(), share @SETTINGS @given(cases) -def test_class_4_reads_the_same_deduction(sims, drawn): - for allowance, sim in sims.items(): - r = run(sim, drawn) - expected = np.maximum(r["profit"] - r["capital"] - r["trade_relief"], 0) - assert ( - np.abs(r["class_4_profits"] - expected) <= tolerance(r["profit"]) - ).all(), allowance +def test_a_smaller_expense_share_never_lowers_a_claimants_taxable_profit(sims, drawn): + for allowance in (0, 300, 500): + taxable = [ + run(sims[(allowance, share)], drawn)["taxable"] for share in (1.0, 0.5, 0.0) + ] + for larger_share, smaller_share in zip(taxable, taxable[1:]): + assert (smaller_share >= larger_share - tolerance(larger_share)).all() diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index 0494d2358e..156ce9c476 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -14,12 +14,24 @@ from policyengine_uk.utils.supplied_inputs import supplied_input +def _data_year(person, period): + """The year the input data were measured, as the State Pension + variables read it: a dataset's first year, which the engine projects to + later years, or without a dataset the year simulated.""" + dataset = getattr(person.simulation, "dataset", None) + try: + return min(dataset.years) + except (AttributeError, TypeError, ValueError): + return period.start.year + + def trading_allowance_reliefs(person, period, parameters): """The allowance's relief on trades and on miscellaneous income. - Returns ``(trade_relief, miscellaneous_relief)``: the deduction from - trading profits beyond the expenses and capital allowances already - deducted from them, and the deduction from income not otherwise charged. + Returns ``(trade_relief, miscellaneous_relief, claimant_takes_allowance)``: + the deduction from trading profits beyond the expenses and capital + allowances already deducted from them, the deduction from income not + otherwise charged, and whether a claimant (below) takes the allowance. - Full relief (s. 783AE(1)): when trade receipts and miscellaneous income together are within the allowance, trade profits or losses and the @@ -60,25 +72,34 @@ def trading_allowance_reliefs(person, period, parameters): income. A larger profit is taken to reflect the better of actual expenses and the allowance already, which leaves none of it. - trading_allowance_claim_status, when known, takes precedence over gross - receipts: - - A claimant's profit is their receipts less the allowance deducted in - the input data (gov.simulation.trading_allowance_in_input_profits), - so their receipts are their profit plus it, and the model reads that - amount as their actual expenses. The relief is then the allowance's - excess over it, less capital allowances (nil for a consistent - claimant, since partial relief replaces them), capped at profit. A - cut below it gives nil: their actual expenses are below the input - allowance but unknown, so their taxable profit would rise by between - nil and the input allowance less the new one, and the model takes - nil, keeping the deduction a relief beyond what the input profit - deducts. Their receipts take up the allowance before income not - otherwise charged, as other receipts do. - - A non-claimant's actual expenses and capital allowances exceed the - input allowance, and their receipts are unknown. Electing for partial - relief after a raise is a change in behaviour, so neither deduction - applies at any allowance. Full relief needs no election, but needs - relevant income within the allowance, which the data cannot show. + trading_allowance_claim_status says which receipts lie behind a positive + profit. A person with no profit has no trade receipts for it to + describe, and a claimant is then read as unknown. + - A claimant's profit is their receipts less the allowance legislated + for the year the input data were measured (the input allowance), + which reforms do not move. Their claim was the election for partial + relief (s. 783AM), so they take the allowance at any level above nil, + which keeps their current losses given up, and it replaced their + expenses and capital allowances (s. 783AI): capital_allowances + supplied for them are not deducted, and their receipts, profit plus + the input allowance, take precedence over gross receipts. The + deduction is the allowance's excess over the input allowance, capped + at profit. Their actual expenses and capital allowances are below the + input allowance but unknown. Under a cut, a claimant whose expenses + exceed the new allowance deducts them instead, so their taxable profit + rises by the input allowance less the larger of the new allowance and + those expenses, which gov.simulation.trading_allowance_claimant_expense_share + sets as a share of the input allowance: by default all of it, so a cut + leaves their profit as reported, the lower bound of its yield. The + deduction is negative where it gives back capital allowances or + charges what a cut adds. Their receipts take up the allowance before + income not otherwise charged, as other receipts do. + - A non-claimant does not elect for partial relief, which would be a + change in behaviour after a raise, so takes ordinary treatment unless + their relevant income is within the allowance: full relief needs no + election (s. 783AE). Their receipts exceed their profit plus the input + allowance and are unknown unless supplied, so only supplied receipts, + or casual income alone where there is no profit, can show that. The miscellaneous income is income_not_otherwise_charged, up to the person's miscellaneous_income when that is positive (it is part of it). @@ -89,33 +110,34 @@ def trading_allowance_reliefs(person, period, parameters): """ p = parameters(period) allowance = p.gov.hmrc.income_tax.allowances.trading_allowance - input_allowance = p.gov.simulation.trading_allowance_in_input_profits - claim_status = person("trading_allowance_claim_status", period) - claimant = claim_status == claim_status.possible_values.CLAIMANT - non_claimant = claim_status == claim_status.possible_values.NON_CLAIMANT + # The baseline tree is the legislated one, which reforms leave alone. It + # is not annualised, so it is read on 30 April, where the fiscal-year + # conversion samples the gov tree. + input_allowance = parameters( + f"{_data_year(person, period)}-04-30" + ).baseline.gov.hmrc.income_tax.allowances.trading_allowance + claimant_expenses = ( + p.gov.simulation.trading_allowance_claimant_expense_share * input_allowance + ) + status = person("trading_allowance_claim_status", period) profit = person("self_employment_income", period) + receipts = person("self_employment_gross_receipts", period) capital_allowances = person("capital_allowances", period) - # A claimant's receipts are their profit plus the allowance it deducts. - receipts = where( - claimant, - max_(profit, 0) + input_allowance, - person("self_employment_gross_receipts", period), - ) - # Otherwise receipts below profit (including the default zero) mean - # unknown. - receipts_known = claimant | ((receipts > 0) & (receipts >= profit)) + trading = profit > 0 + claimant = trading & (status == status.possible_values.CLAIMANT) + non_claimant = status == status.possible_values.NON_CLAIMANT + + # Receipts below profit (including the default zero) mean unknown. + receipts_known = (receipts > 0) & (receipts >= profit) expenses = max_(receipts - profit, 0) # Full relief (receipts within the allowance, s. 783AE-783AF) makes # the profit nil; partial relief (s. 783AI) replaces expenses and # capital allowances with the allowance. Both come to the allowance's - # excess over actual deductions, capped at profit. For a claimant the - # input allowance stands in for their expenses. + # excess over actual deductions, capped at profit. relief_with_receipts = min_(profit, allowance - expenses) - capital_allowances relief_without_receipts = where(profit <= allowance, profit - capital_allowances, 0) trade_relief = where(receipts_known, relief_with_receipts, relief_without_receipts) - # A non-claimant deducts actual expenses above the input allowance. - trade_relief = where(non_claimant, 0, max_(trade_relief, 0)) - + trade_relief = max_(trade_relief, 0) # The trade receipts that use the allowance first. Unknown receipts are # the profit within the allowance, and otherwise use all of it. trade_receipts = where( @@ -123,15 +145,22 @@ def trading_allowance_reliefs(person, period, parameters): receipts, where(profit <= allowance, max_(profit, 0), allowance), ) + # A claimant keeps the allowance while it is at least their expenses, + # and otherwise deducts those; either way the deduction gives back the + # capital allowances deducted separately. + claimant_relief = ( + min_(profit, max_(allowance, claimant_expenses) - input_allowance) + - capital_allowances + ) + trade_relief = where(claimant, claimant_relief, trade_relief) + trade_receipts = where(claimant, profit + input_allowance, trade_receipts) + miscellaneous_income = min_( max_(person("income_not_otherwise_charged", period), 0), max_(person("miscellaneous_income", period), 0), ) - # A non-claimant takes ordinary treatment of all their relevant income. - miscellaneous_relief = where( - non_claimant, - 0, - min_(miscellaneous_income, max_(allowance - trade_receipts, 0)), + miscellaneous_relief = min_( + miscellaneous_income, max_(allowance - trade_receipts, 0) ) # Full relief makes every relevant trade's current profit AND loss @@ -154,8 +183,19 @@ def trading_allowance_reliefs(person, period, parameters): 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 + # A non-claimant gets the allowance only through full relief, where + # supplied receipts, or casual income alone with no profit, show + # relevant income within it. + within_allowance = where( + receipts_known, + receipts + miscellaneous_income <= allowance, + ~trading & (miscellaneous_income <= allowance), + ) + ordinary = (non_claimant & ~within_allowance) | (current_loss > allowance_saving) + # A claimant's election is their claim, not this comparison. + ordinary = ordinary & ~claimant return ( - where(claim_current_loss, 0, trade_relief), - where(claim_current_loss, 0, miscellaneous_relief), + where(ordinary, 0, trade_relief), + where(ordinary, 0, miscellaneous_relief), + claimant & (allowance > 0), ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py index c320612c74..967dba38c7 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py @@ -20,14 +20,19 @@ class miscellaneous_income_trading_allowance_deduction(Variable): "contributions exceed relevant UK earnings (see " "utils/trading_allowance.py). Ordinary treatment to claim a current " "trading loss (no full relief, s. 783AL, or no partial relief, s. " - "783AM) leaves it unrelieved, as it does for a non-claimant " - "(trading_allowance_claim_status). Only " + "783AM) leaves it unrelieved. A non-claimant " + "(trading_allowance_claim_status) gets it only through full relief, " + "and a claimant's receipts, profit plus the allowance it deducts, " + "take up the allowance first. Only " "income tax reads this deduction, through taxable_miscellaneous_income; " "means tests and gross income bases read " "miscellaneous_income_before_trading_allowance. Datasets that hold " - "income already net of the allowance should set it to zero, as " - "policyengine-uk-data does for trading_allowance_deduction on SPI " - "records." + "this income already net of the allowance should set this deduction " + "to zero. For trade profits already net of it, mark claimants and " + "non-claimants in trading_allowance_claim_status rather than set " + "trading_allowance_deduction to zero, as policyengine-uk-data does on " + "SPI records, which keeps any change to the allowance from reaching " + "them." ) definition_period = YEAR reference = [ @@ -51,5 +56,7 @@ class miscellaneous_income_trading_allowance_deduction(Variable): unit = GBP def formula(person, period, parameters): - _, miscellaneous_relief = trading_allowance_reliefs(person, period, parameters) + _, miscellaneous_relief, _ = trading_allowance_reliefs( + person, period, parameters + ) return miscellaneous_relief 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 528d0e815f..e9b749b391 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 @@ -18,18 +18,20 @@ 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. trading_allowance_claim_status, " - "when known, takes precedence over receipts. A claimant's profit " - "already deducts the allowance in force when it was measured " - "(gov.simulation.trading_allowance_in_input_profits), so their " - "receipts are their profit plus it and the deduction is the " - "allowance's excess over it, capped at profit. A cut below that " - "amount would raise a claimant's taxable profit by up to the " - "difference, depending on expenses the data do not hold; the model " - "leaves it as reported. A non-claimant deducts actual expenses above " - "it and gets no deduction at any allowance, since electing for the " - "allowance after a raise is a change in behaviour. " - "The model compares the raw loss " + "inconsistent and treated as unknown. For a claimant " + "(trading_allowance_claim_status) with a profit, the profit already " + "deducts the allowance legislated for the year the input data were " + "measured, so their receipts are their profit plus it, whatever " + "receipts are supplied, and the deduction is the allowance's excess " + "over it, capped at profit. It also gives back any capital allowances " + "supplied for them, which partial relief replaces, so it can be " + "negative. Under a cut below the input allowance it charges the rise " + "that gov.simulation.trading_allowance_claimant_expense_share sets " + "(by default none). A non-claimant gets the deduction only through " + "full relief, where supplied receipts show relevant income within the " + "allowance: electing for partial relief after a raise is a change in " + "behaviour. A claimant's claim is their election; for everyone else " + "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, " "including its relief on miscellaneous income. " @@ -71,5 +73,5 @@ class trading_allowance_deduction(Variable): unit = GBP def formula(person, period, parameters): - trade_relief, _ = trading_allowance_reliefs(person, period, parameters) + trade_relief, _, _ = trading_allowance_reliefs(person, period, parameters) return trade_relief diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py index 588605e2e8..c3b994932b 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.trading_allowance import trading_allowance_reliefs class uses_trading_allowance(Variable): @@ -15,7 +16,10 @@ class uses_trading_allowance(Variable): "cannot then be claimed. A person with a larger current loss takes " "ordinary treatment and claims it: within the allowance by electing " "out of full relief (s. 783AL), above it by not electing for partial " - "relief (s. 783AM)." + "relief (s. 783AM). A claimant in the input data " + "(trading_allowance_claim_status) takes it at any allowance above " + "nil, even where it relieves nothing beyond their input profit: " + "their claim was the election, and it gave up their current losses." ) definition_period = YEAR reference = [ @@ -34,6 +38,11 @@ class uses_trading_allowance(Variable): ] def formula(person, period, parameters): - return (person("trading_allowance_deduction", period) > 0) | ( - person("miscellaneous_income_trading_allowance_deduction", period) > 0 + _, _, claimant_takes_allowance = trading_allowance_reliefs( + person, period, parameters + ) + return ( + (person("trading_allowance_deduction", period) > 0) + | (person("miscellaneous_income_trading_allowance_deduction", period) > 0) + | claimant_takes_allowance ) diff --git a/policyengine_uk/variables/input/income_not_otherwise_charged.py b/policyengine_uk/variables/input/income_not_otherwise_charged.py index 52449a6585..37bb8641e2 100644 --- a/policyengine_uk/variables/input/income_not_otherwise_charged.py +++ b/policyengine_uk/variables/input/income_not_otherwise_charged.py @@ -19,10 +19,12 @@ class income_not_otherwise_charged(Variable): "Part 5 Chapters and, on SPI records, benefits and termination " "payments, none of which the allowance relieves. Without " "self_employment_gross_receipts, a trade with a loss or no profit is " - "taken to have no receipts, so its relevant income is this alone. " - "A claimant's trade receipts (trading_allowance_claim_status) take up " - "the allowance first, and a non-claimant gets none of it on this " - "income." + "taken to have no receipts, so its relevant income is this alone, " + "whatever the person's trading_allowance_claim_status. A claimant's " + "trade " + "receipts, profit plus the allowance it deducts, take up the " + "allowance first, and a non-claimant gets it on this income only " + "through full relief." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/self_employment_gross_receipts.py b/policyengine_uk/variables/input/self_employment_gross_receipts.py index a276020fa2..9b3c943a82 100644 --- a/policyengine_uk/variables/input/self_employment_gross_receipts.py +++ b/policyengine_uk/variables/input/self_employment_gross_receipts.py @@ -11,8 +11,9 @@ class self_employment_gross_receipts(Variable): "against gross receipts and replaces actual expenses. Must be at least " "self_employment_income; leave at zero when unknown. " "self_employment_income remains the profit used everywhere else. " - "A claimant or non-claimant trading_allowance_claim_status takes " - "precedence over it." + "For a claimant (trading_allowance_claim_status) with a profit, the " + "receipts are that profit plus the allowance it deducts, whatever is " + "supplied here." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/trading_allowance_claim_status.py b/policyengine_uk/variables/input/trading_allowance_claim_status.py index 1fe4b18bf7..21a967c044 100644 --- a/policyengine_uk/variables/input/trading_allowance_claim_status.py +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -18,25 +18,37 @@ class trading_allowance_claim_status(Variable): "their input self-employment profit was measured. Survey data record " "profit, not gross receipts (turnover), and this says which receipts " "lie behind it, so that a change to the allowance reaches the right " - "people. CLAIMANT: the profit is receipts less the allowance in force " - "when it was measured (gov.simulation.trading_allowance_in_input_profits), " - "as on a return claiming partial relief (ITTOIA 2005 s. 783AI), so the " - "receipts are that profit plus that allowance. Flag only profits net " - "of the allowance: a person whose receipts were within it was fully " - "relieved, and their profit, if reported, is not net of it; leave them " - "UNKNOWN. NON_CLAIMANT: the profit is receipts less actual expenses and " - "capital allowances above that allowance. Their receipts are unknown, " - "and electing for the allowance after a raise is a change in " - "behaviour, so the allowance relieves neither their trades nor their " - "income not otherwise charged, at any level. UNKNOWN, the default: " - "the allowance applies to self_employment_gross_receipts when they " - "are supplied, and otherwise treats a profit within the allowance as " - "receipts within it. A claimant or non-claimant status takes " - "precedence over self_employment_gross_receipts. For profits already " - "net of the allowance, marking claimants and non-claimants here gives " - "no deduction while the allowance stays at the input allowance, as " - "setting trading_allowance_deduction to zero does, but lets a change " - "to the allowance reach them." + "people. The year is the dataset's first year, which the engine " + "projects to later years, or without a dataset the year simulated; " + "the model reads the allowance legislated for it, which reforms do " + "not move. The status describes a positive profit: a person with no " + "profit has no trade receipts for it to describe, and a claimant is " + "then read as UNKNOWN. CLAIMANT: the profit is receipts less that " + "allowance, as on a return claiming partial relief (ITTOIA 2005 s. " + "783AI), so the receipts are that profit plus the allowance, whatever " + "self_employment_gross_receipts says. Partial relief replaces " + "expenses and capital allowances, so capital_allowances supplied for " + "a claimant are not deducted, and their claim was the election (s. " + "783AM), so they keep the allowance, and give up current trading " + "losses, at any allowance above nil. " + "gov.simulation.trading_allowance_claimant_expense_share sets what a " + "cut adds to their profit. Flag only profits net of the allowance: a " + "person whose receipts were within it was fully relieved without a " + "claim, and their profit, if reported, is not net of it; leave them " + "UNKNOWN. NON_CLAIMANT: the profit is receipts less actual expenses, " + "with capital_allowances deducted from it separately, and those " + "deductions exceed the allowance. They did not elect for partial " + "relief, and electing after a raise is a change in behaviour, so the " + "allowance relieves their trades and their income not otherwise " + "charged only through full relief, which needs no election (s. " + "783AE): where supplied receipts, or that income alone with no " + "profit, show relevant income within the allowance. UNKNOWN, the " + "default: the allowance applies to self_employment_gross_receipts " + "when they are supplied, and otherwise treats a profit within the " + "allowance as receipts within it. For profits already net of the " + "allowance, mark claimants and non-claimants here rather than set " + "trading_allowance_deduction to zero, which keeps any change to the " + "allowance from reaching them." ) definition_period = YEAR reference = [ From 6b9a638d738df44e14111e70646068727af5da03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:36:53 +0000 Subject: [PATCH 3/9] Record the situation data-year limitation and pin it with a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a dataset the year simulated stands in for the year the inputs were measured, even for inputs supplied for an earlier year and carried forward, so a claimant given 2025-26 inputs and simulated in 2027-28 is read as net of £3,000 and gets nothing from the raise. Keep that convention, shared with the State Pension variables, and say so in _data_year, the claim status input and the deduction's documentation, with the workaround (build a dataset for the earlier year). A test pins the behaviour so that a change of convention has to update the docs. Co-Authored-By: Claude Opus 5.5 --- ...rading_allowance_claim_status_data_year.py | 28 +++++++++++++++++-- policyengine_uk/utils/trading_allowance.py | 16 +++++++++-- .../allowances/trading_allowance_deduction.py | 4 ++- .../input/trading_allowance_claim_status.py | 8 +++++- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py index 2319651d92..2bae450f84 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py @@ -114,10 +114,32 @@ def test_the_status_carries_forward_from_the_year_supplied(): assert status.tolist() == ["CLAIMANT"] +def test_a_situation_reads_carried_inputs_as_measured_in_the_year_simulated(): + # Pins a known limitation (utils/trading_allowance.py, _data_year). + # Without a dataset the year simulated stands in for the year the inputs + # were measured, even for inputs supplied for an earlier year. This + # claimant's profit was net of 2025-26's £1,000, but in 2027-28 it is + # read as net of £3,000, so the raise gives nothing. The same inputs in a + # dataset measured in 2025 get £2,000 + # (test_the_budget_raise_reaches_claimants_measured_at_1000). If this + # convention changes, update the docs that describe the limitation. + simulation = Simulation( + situation=situation( + { + "age": {2025: 40}, + "self_employment_income": {2025: 5_000}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + ) + ) + assert deduction(simulation, 2027) == [0] + + def test_a_raise_leaves_a_situation_claimants_carried_loss(): - # Without a dataset the data year is the year simulated, so this - # claimant's 2027 profit already deducts £3,000, and keeping £1,000 is a - # cut. Either way they keep the allowance and give up the loss. + # Without a dataset the data year is the year simulated (the limitation + # above), so this claimant's 2027 profit already deducts £3,000, and + # keeping £1,000 is a cut. Either way they keep the allowance and give + # up the loss. person = { "age": {2025: 40}, "employment_income": {2025: 30_000}, diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index 156ce9c476..24bff336a2 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -17,7 +17,17 @@ def _data_year(person, period): """The year the input data were measured, as the State Pension variables read it: a dataset's first year, which the engine projects to - later years, or without a dataset the year simulated.""" + later years, or without a dataset the year simulated. + + Known limitation: a situation does not record when its inputs were + measured, so the year simulated stands in even for inputs supplied for + an earlier year and carried forward. A claimant whose inputs are given + for 2025-26 and simulated in 2027-28 is read as having deducted + 2027-28's £3,000 allowance, not the £1,000 they claimed against, and + gets nothing from the raise between them. Datasets are not affected. To + read inputs as measured in an earlier year, build a dataset for that + year, as test_trading_allowance_claim_status_data_year.py does. + """ dataset = getattr(person.simulation, "dataset", None) try: return min(dataset.years) @@ -77,7 +87,9 @@ def trading_allowance_reliefs(person, period, parameters): describe, and a claimant is then read as unknown. - A claimant's profit is their receipts less the allowance legislated for the year the input data were measured (the input allowance), - which reforms do not move. Their claim was the election for partial + which reforms do not move. Without a dataset that year is the year + simulated, even for inputs carried from an earlier year: a known + limitation (see _data_year). Their claim was the election for partial relief (s. 783AM), so they take the allowance at any level above nil, which keeps their current losses given up, and it replaced their expenses and capital allowances (s. 783AI): capital_allowances 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 e9b749b391..78a1219fce 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 @@ -21,7 +21,9 @@ class trading_allowance_deduction(Variable): "inconsistent and treated as unknown. For a claimant " "(trading_allowance_claim_status) with a profit, the profit already " "deducts the allowance legislated for the year the input data were " - "measured, so their receipts are their profit plus it, whatever " + "measured (without a dataset, the year simulated, even for inputs " + "carried from an earlier year: a known limitation), so their " + "receipts are their profit plus it, whatever " "receipts are supplied, and the deduction is the allowance's excess " "over it, capped at profit. It also gives back any capital allowances " "supplied for them, which partial relief replaces, so it can be " diff --git a/policyengine_uk/variables/input/trading_allowance_claim_status.py b/policyengine_uk/variables/input/trading_allowance_claim_status.py index 21a967c044..9a9ce58148 100644 --- a/policyengine_uk/variables/input/trading_allowance_claim_status.py +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -21,7 +21,13 @@ class trading_allowance_claim_status(Variable): "people. The year is the dataset's first year, which the engine " "projects to later years, or without a dataset the year simulated; " "the model reads the allowance legislated for it, which reforms do " - "not move. The status describes a positive profit: a person with no " + "not move. Known limitation: without a dataset the year simulated " + "stands in even for inputs supplied for an earlier year and carried " + "forward, so a claimant whose inputs are for 2025-26, simulated in " + "2027-28, is read as net of 2027-28's £3,000 allowance and gets " + "nothing from the raise from £1,000. To read inputs as measured in " + "an earlier year, build a dataset for that year. The status " + "describes a positive profit: a person with no " "profit has no trade receipts for it to describe, and a claimant is " "then read as UNKNOWN. CLAIMANT: the profit is receipts less that " "allowance, as on a return claiming partial relief (ITTOIA 2005 s. " From 894450054132b6794797b0776a485de94ad24a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:11:13 +0000 Subject: [PATCH 4/9] Address re-review: claimant cuts, situation data year, share bounds A claimant takes the allowance only while it is above nil and at least their expenses (the expense share of the input allowance); below them they deduct those expenses, which is ordinary treatment, so a supplied current loss is claimable under any cut, not only under abolition. A situation's data year is now the latest year, up to the one simulated, for which trading_allowance_claim_status was supplied (read through supplied_input_periods; the status joins SUPPLIED_INPUT_VARIABLES), so inputs carried from an earlier year keep that year's allowance. Microdata keep their first year, chosen through built_from_data. This replaces the limitation recorded in 6b9a638d7. The expense share is clamped to [0, 1]. A dataset still cannot declare the year its incomes were measured; that is now documented. Tests: the property test's reference derives a claimant's choice by comparing the allowance with deducting their expenses, and gains a scenario at a share of 1.5; YAML cases cover a cut with a current loss, full relief exactly at the allowance and a share above 1; the situation data-year tests now check the supplied year. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2764.added.md | 2 +- ...ding_allowance_claimant_expense_share.yaml | 13 ++- .../trading_allowance_claim_status.yaml | 66 ++++++++++- ...rading_allowance_claim_status_data_year.py | 57 ++++++---- ...ading_allowance_claim_status_properties.py | 53 ++++++--- policyengine_uk/utils/supplied_inputs.py | 1 + policyengine_uk/utils/trading_allowance.py | 104 ++++++++++-------- .../allowances/trading_allowance_deduction.py | 12 +- .../allowances/uses_trading_allowance.py | 8 +- .../input/trading_allowance_claim_status.py | 30 ++--- 10 files changed, 237 insertions(+), 109 deletions(-) diff --git a/changelog.d/2764.added.md b/changelog.d/2764.added.md index 89b6011e18..d3abf0391a 100644 --- a/changelog.d/2764.added.md +++ b/changelog.d/2764.added.md @@ -1 +1 @@ -Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the data were measured (a dataset's first year, or the year simulated without one), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted, and they keep the allowance, giving up current losses, at any level above nil. A cut below that allowance adds to their profit what `gov.simulation.trading_allowance_claimant_expense_share` sets, by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. +Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the data were measured (a dataset's first year, or the latest year a situation supplied the status for), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1): they keep the allowance, giving up current losses, while it is above nil and at least those expenses, and otherwise deduct them, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. diff --git a/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml b/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml index 4fbd2c2d8b..6e1a7a6f41 100644 --- a/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml +++ b/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml @@ -1,4 +1,15 @@ -description: Actual expenses and capital allowances of a trading allowance claimant, as a share of the allowance their input profit already deducts. It sets how much a cut below that allowance adds to the taxable profit of people the input data mark as claimants (trading_allowance_claim_status). A claimant keeps claiming while the allowance is at least these expenses, and otherwise deducts them instead, so a cut adds the input allowance less the larger of the new allowance and these expenses. The allowance at or above the input allowance is unaffected. +description: >- + Actual expenses and capital allowances of a trading allowance claimant, as a + share of the allowance their input profit already deducts. It sets how much + a cut below that allowance adds to the taxable profit of people the input + data mark as claimants (trading_allowance_claim_status). A claimant keeps + claiming while the allowance is at least these expenses, and otherwise + deducts them instead, which is ordinary treatment and leaves a supplied + current loss claimable, so a cut adds the input allowance less the larger of + the new allowance and these expenses. The share is between 0 and 1, and a + value outside is taken as the nearer bound: expenses cannot be negative, and + above the allowance the claimant would have deducted them instead. An + allowance at or above the input allowance is unaffected. # A claimant's expenses and capital allowances are below the allowance they # claim, or they would deduct them instead, but no survey records them. With # a share of 1, the default, they equal it, so a cut leaves a claimant's diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml index d5dcef711c..79f2707d09 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml @@ -1,13 +1,16 @@ # trading_allowance_claim_status says which receipts lie behind a person's # input profit (ITTOIA 2005 Part 6A). The input allowance is the allowance # legislated for the year the data were measured: a dataset's first year, or -# here, without a dataset, the year simulated. Reforms do not move it. +# in a situation like these the latest year the status was supplied for, here +# the year simulated. Reforms do not move it. # - Claimant: the profit is receipts less the input allowance, as under # partial relief (s. 783AI), so receipts are profit + input allowance and # the deduction at allowance A is min(profit, A - input allowance). Partial # relief replaces capital allowances, so a claimant's are not deducted. # Their claim was the election (s. 783AM), so they keep the allowance, and -# give up current losses, at any A above nil. +# give up current losses, while A is above nil and at least their expenses +# (gov.simulation.trading_allowance_claimant_expense_share of the input +# allowance, by default all of it); below those they deduct them instead. # - Non-claimant: no election for partial relief; full relief needs none # (s. 783AE) and applies where supplied receipts, or casual income alone # with no profit, are within the allowance. @@ -167,6 +170,19 @@ trading_allowance_deduction: -500 taxable_self_employment_income: 3_000 +- name: An expense share above 1 is taken as 1, so today's law gives a claimant nothing more. + period: 2026 + absolute_error_margin: 0 + input: + gov.simulation.trading_allowance_claimant_expense_share: 1.5 + self_employment_income: 2_500 + trading_allowance_claim_status: CLAIMANT + output: + # Expenses above the allowance they chose would contradict their claim. + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 + uses_trading_allowance: true + - name: Abolishing the allowance leaves a claimant's profit as reported. period: 2026 absolute_error_margin: 0 @@ -332,6 +348,21 @@ miscellaneous_income_trading_allowance_deduction: 900 taxable_miscellaneous_income: 0 +- name: A non-claimant's relevant income exactly at the allowance is fully relieved. + period: 2027 + absolute_error_margin: 0 + input: + self_employment_gross_receipts: 2_100 + self_employment_income: 600 + miscellaneous_income: 900 + income_not_otherwise_charged: 900 + trading_allowance_claim_status: NON_CLAIMANT + output: + # 2,100 + 900 = 3,000 does not exceed the allowance (s. 783AE). + trading_allowance_deduction: 600 + miscellaneous_income_trading_allowance_deduction: 900 + taxable_miscellaneous_income: 0 + - name: A non-claimant's receipts and casual income above the allowance get nothing. period: 2027 absolute_error_margin: 0 @@ -363,6 +394,17 @@ # (30,000 - 12,570) x 20% income_tax: 3_486 +- name: A non-claimant with no trade has casual income exactly at the allowance relieved. + period: 2027 + absolute_error_margin: 0 + input: + miscellaneous_income: 3_000 + income_not_otherwise_charged: 3_000 + trading_allowance_claim_status: NON_CLAIMANT + output: + miscellaneous_income_trading_allowance_deduction: 3_000 + taxable_miscellaneous_income: 0 + - name: Unknown status still relieves a profit within the allowance. period: 2027 absolute_error_margin: 0 @@ -480,6 +522,26 @@ # (30,000 + 2,500 - 1,500 - 12,570) x 20% income_tax: 3_686 +- name: A cut below a claimant's expenses lets them claim a current loss. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 500 + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 1_500 + trading_allowance_claim_status: CLAIMANT + output: + # By default their expenses are £1,000, which beat a £500 allowance: + # they deduct them, ordinary treatment (s. 783AM), so the loss is + # claimable, as it is under abolition. + trading_allowance_deduction: 0 + taxable_self_employment_income: 2_500 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 1_500 + # (30,000 + 2,500 - 1,500 - 12,570) x 20% + income_tax: 3_686 + - name: A non-claimant claims a current loss the allowance would have taken. period: 2027 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py index 2bae450f84..bff30416f1 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py @@ -2,11 +2,12 @@ input data were measured (PolicyEngine/policyengine-uk#2764). A claimant's profit already deducts the allowance legislated for that year: -a dataset's first year, which Simulation projects to later years, or -without a dataset the year simulated. These tests build small datasets, so -the data year and the simulated year differ, as they do for survey data -projected past the April 2027 raise to £3,000 (Budget 2026, paragraph -6.254). +a dataset's first year, which Simulation projects to later years, or in a +situation the latest year, up to the one simulated, for which the status was +supplied. These tests build small datasets and situations with earlier +inputs, so the data year and the simulated year differ, as they do for +survey data projected past the April 2027 raise to £3,000 (Budget 2026, +paragraph 6.254). """ import pandas as pd @@ -114,15 +115,10 @@ def test_the_status_carries_forward_from_the_year_supplied(): assert status.tolist() == ["CLAIMANT"] -def test_a_situation_reads_carried_inputs_as_measured_in_the_year_simulated(): - # Pins a known limitation (utils/trading_allowance.py, _data_year). - # Without a dataset the year simulated stands in for the year the inputs - # were measured, even for inputs supplied for an earlier year. This - # claimant's profit was net of 2025-26's £1,000, but in 2027-28 it is - # read as net of £3,000, so the raise gives nothing. The same inputs in a - # dataset measured in 2025 get £2,000 - # (test_the_budget_raise_reaches_claimants_measured_at_1000). If this - # convention changes, update the docs that describe the limitation. +def test_a_situation_reads_carried_inputs_as_measured_in_the_year_supplied(): + # This claimant's profit was net of 2025-26's £1,000, so in 2027-28 the + # raise to £3,000 gives them £2,000, as the same inputs in a dataset + # measured in 2025 do (test_the_budget_raise_reaches_claimants_measured_at_1000). simulation = Simulation( situation=situation( { @@ -132,14 +128,32 @@ def test_a_situation_reads_carried_inputs_as_measured_in_the_year_simulated(): } ) ) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + + +def test_a_situation_reads_each_year_supplied_as_measured_then(): + # Inputs supplied for 2027 were measured under 2027-28's £3,000, so they + # gain nothing from it; 2026 still reads the 2025 inputs, at £1,000. + simulation = Simulation( + situation=situation( + { + "age": {2025: 40}, + "self_employment_income": {2025: 5_000, 2027: 5_000}, + "trading_allowance_claim_status": { + 2025: "CLAIMANT", + 2027: "CLAIMANT", + }, + } + ) + ) + assert deduction(simulation, 2026) == [0] assert deduction(simulation, 2027) == [0] -def test_a_raise_leaves_a_situation_claimants_carried_loss(): - # Without a dataset the data year is the year simulated (the limitation - # above), so this claimant's 2027 profit already deducts £3,000, and - # keeping £1,000 is a cut. Either way they keep the allowance and give - # up the loss. +def test_a_raise_never_withdraws_a_situation_claimants_loss_relief(): + # The situation's inputs are for 2025, so in 2027 the raise to £3,000 + # gives this claimant £2,000. Their claim gave the loss up at £1,000 and + # still does at £3,000, so the raise only lowers their tax. person = { "age": {2025: 40}, "employment_income": {2025: 30_000}, @@ -150,6 +164,5 @@ def test_a_raise_leaves_a_situation_claimants_carried_loss(): kept = {ALLOWANCE: {"2027": 1_000}} law = Simulation(situation=situation(person)) at_1000 = Simulation(situation=situation(person), reform=kept) - assert law.calculate("income_tax", 2027)[0] == pytest.approx( - at_1000.calculate("income_tax", 2027)[0], abs=0.01 - ) + tax = law.calculate("income_tax", 2027)[0] + assert tax == pytest.approx(at_1000.calculate("income_tax", 2027)[0] - 400, abs=1) diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py index c6f5f2cbd4..5815459d0b 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py @@ -3,10 +3,11 @@ Survey data hold profit, not receipts. The claim status says which receipts lie behind a positive profit. A claimant's profit already deducts the -allowance legislated for the year the data were measured (here, without a -dataset, the year simulated: £1,000 in 2026-27), so their receipts are that -profit plus £1,000. A non-claimant did not elect for partial relief. Unknown, -the default, keeps the rules for supplied or missing receipts. +allowance legislated for the year the data were measured (here, a situation +whose status is supplied for the year simulated: £1,000 in 2026-27), so +their receipts are that profit plus £1,000. A non-claimant did not elect for +partial relief. Unknown, the default, keeps the rules for supplied or +missing receipts. The reference below is written from the Act's options rather than from the model's formula: full relief applies without an election whenever relevant @@ -14,7 +15,9 @@ (s. 783AM), which a non-claimant has not made and a claimant has; ordinary treatment deducts actual expenses and capital allowances, which for a claimant partial relief replaced and are taken as a share of the input -allowance (gov.simulation.trading_allowance_claimant_expense_share). +allowance (gov.simulation.trading_allowance_claimant_expense_share), between +none and all of it. A claimant keeps the allowance while it does at least as +well for the trade as those expenses. Invariants, each checked over many generated people in one reusable simulation per allowance level and expense share: @@ -42,9 +45,12 @@ ALLOWANCES = [0, 300, 500, 1_000, 2_000, 3_000, 5_000, 10_000] # (allowance, claimant expense share): every allowance at the default share, # and cuts at lower shares. -SCENARIOS = [(allowance, 1.0) for allowance in ALLOWANCES] + [ - (allowance, share) for allowance in (0, 300, 500) for share in (0.0, 0.5) -] +SCENARIOS = ( + [(allowance, 1.0) for allowance in ALLOWANCES] + + [(allowance, share) for allowance in (0, 300, 500) for share in (0.0, 0.5)] + # A share above 1 would put expenses above the allowance claimed. + + [(1_000, 1.5)] +) STATUSES = ["UNKNOWN", "CLAIMANT", "NON_CLAIMANT"] PEOPLE = 30 @@ -169,8 +175,10 @@ def reference(r, allowance, share): profit, ) # Ordinary treatment deducts actual expenses and capital allowances; a - # claimant's are the share of the input allowance. - ordinary = np.where(claimant, receipts - share * INPUT_ALLOWANCE, profit - capital) + # claimant's are a share of the input allowance, between none and all of + # it, since they chose the allowance instead. + claimant_expenses = min(max(share, 0), 1) * INPUT_ALLOWANCE + ordinary = np.where(claimant, receipts - claimant_expenses, profit - capital) # Full relief (no election) or partial relief, trades first (s. 783AK). full = receipts + chapter_8 <= allowance with_allowance = np.where(full, 0, np.maximum(receipts - allowance, 0)) @@ -179,8 +187,14 @@ def reference(r, allowance, share): available = full | ~non_claimant trade = np.where(available, np.minimum(with_allowance, ordinary), ordinary) chapter_8_relief = np.where(available, chapter_8_relief, 0) - # A claimant's claim is their election. Anyone else claims a current - # loss larger than the allowance's saving instead (ss. 783AL, 783AM). + # A claimant's claim is their election: they keep the allowance while + # there is one and it does at least as well for the trade as deducting + # their expenses, and otherwise take ordinary treatment. A current loss + # does not enter: their claim set it aside. + claimant_takes = claimant & (allowance > 0) & (with_allowance <= ordinary) + chapter_8_relief = np.where(claimant & ~claimant_takes, 0, chapter_8_relief) + # Anyone else claims a current loss larger than the allowance's saving + # instead (ss. 783AL, 783AM). saving = ( np.maximum(ordinary - brought_forward, 0) - np.maximum(trade - brought_forward, 0) @@ -191,19 +205,20 @@ def reference(r, allowance, share): chapter_8_relief = np.where(claims_loss, 0, chapter_8_relief) takes_allowance = np.where( claimant, - allowance > 0, + claimant_takes, ~claims_loss & ((ordinary - trade > 0) | (chapter_8_relief > 0)), ) # The model sums in float32, so where the reference compares a sum with # a threshold it may land on the other side within rounding: a - # non-claimant's relevant income against the allowance, a supplied loss - # against the saving, and, for whether the allowance is taken, its - # advantage on the trade against nil. + # non-claimant's relevant income against the allowance, anyone else's + # supplied loss against the saving, and, for whether they take the + # allowance, its advantage on the trade against nil. A claimant's choice + # compares the allowances alone, exactly. margin = tolerance(r["receipts"], profit, chapter_8, allowance) finite = np.isfinite(receipts) near_full = non_claimant & finite near_full &= np.abs(np.where(finite, receipts, 0) + chapter_8 - allowance) <= margin - near_loss = (loss > 0) & (np.abs(loss - saving) <= margin) + near_loss = ~claimant & (loss > 0) & (np.abs(loss - saving) <= margin) advantage = np.where(np.isfinite(with_allowance), ordinary - with_allowance, -1) return dict( taxable=np.maximum(trade - brought_forward, 0), @@ -211,7 +226,9 @@ def reference(r, allowance, share): chapter_8_relief=chapter_8_relief, uses=takes_allowance, fragile=near_full | near_loss, - fragile_uses=near_full | near_loss | (np.abs(advantage) <= margin), + fragile_uses=near_full + | near_loss + | (~claimant & (np.abs(advantage) <= margin)), ) diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 38897f81bf..db65729b02 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -82,6 +82,7 @@ "ni_epg_full_rate_applies", "tenure_type", "targeted_childcare_entitlement", + "trading_allowance_claim_status", "trading_loss", "uc_carer_element", "uc_LCWRA_element", diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index 24bff336a2..5e29d2e637 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -11,28 +11,44 @@ from policyengine_core.model_api import max_, min_, where -from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.utils.data_source import built_from_data +from policyengine_uk.utils.supplied_inputs import ( + supplied_input, + supplied_input_periods, +) def _data_year(person, period): - """The year the input data were measured, as the State Pension - variables read it: a dataset's first year, which the engine projects to - later years, or without a dataset the year simulated. - - Known limitation: a situation does not record when its inputs were - measured, so the year simulated stands in even for inputs supplied for - an earlier year and carried forward. A claimant whose inputs are given - for 2025-26 and simulated in 2027-28 is read as having deducted - 2027-28's £3,000 allowance, not the £1,000 they claimed against, and - gets nothing from the raise between them. Datasets are not affected. To - read inputs as measured in an earlier year, build a dataset for that - year, as test_trading_allowance_claim_status_data_year.py does. + """The year the input data were measured. + + - Microdata: their first year, which the engine projects to later years, + as the State Pension variables read it. The status is stored for every + projected year, so the years it was supplied for say nothing here. + - A situation: the latest year, up to the one simulated, for which + trading_allowance_claim_status was supplied. Inputs supplied for an + earlier year and carried forward keep that year's allowance: a + claimant given 2025-26 inputs and simulated in 2027-28 deducted £1,000, + and gains from the raise to £3,000. One year serves the whole + situation, so people whose status was supplied for different years + all take the latest. With no status supplied, the year simulated. + + Not handled: a dataset cannot declare the year its incomes were measured + in, so a file saved from a later projected year is read as measured in + its first year. """ - dataset = getattr(person.simulation, "dataset", None) - try: - return min(dataset.years) - except (AttributeError, TypeError, ValueError): - return period.start.year + simulation = person.simulation + if built_from_data(simulation): + years = getattr(getattr(simulation, "dataset", None), "years", None) + if years: + return min(years) + supplied = [ + supplied_period + for supplied_period in supplied_input_periods( + person, "trading_allowance_claim_status" + ) + if supplied_period.start <= period.start + ] + return (supplied[-1] if supplied else period).start.year def trading_allowance_reliefs(person, period, parameters): @@ -86,26 +102,25 @@ def trading_allowance_reliefs(person, period, parameters): profit. A person with no profit has no trade receipts for it to describe, and a claimant is then read as unknown. - A claimant's profit is their receipts less the allowance legislated - for the year the input data were measured (the input allowance), - which reforms do not move. Without a dataset that year is the year - simulated, even for inputs carried from an earlier year: a known - limitation (see _data_year). Their claim was the election for partial - relief (s. 783AM), so they take the allowance at any level above nil, - which keeps their current losses given up, and it replaced their - expenses and capital allowances (s. 783AI): capital_allowances - supplied for them are not deducted, and their receipts, profit plus - the input allowance, take precedence over gross receipts. The - deduction is the allowance's excess over the input allowance, capped - at profit. Their actual expenses and capital allowances are below the - input allowance but unknown. Under a cut, a claimant whose expenses - exceed the new allowance deducts them instead, so their taxable profit - rises by the input allowance less the larger of the new allowance and - those expenses, which gov.simulation.trading_allowance_claimant_expense_share - sets as a share of the input allowance: by default all of it, so a cut - leaves their profit as reported, the lower bound of its yield. The - deduction is negative where it gives back capital allowances or - charges what a cut adds. Their receipts take up the allowance before - income not otherwise charged, as other receipts do. + for the year the input data were measured (the input allowance, see + _data_year), which reforms do not move. Their claim was the election + for partial relief (s. 783AM), and it replaced their expenses and + capital allowances (s. 783AI): capital_allowances supplied for them + are not deducted, and their receipts, profit plus the input + allowance, take precedence over gross receipts. Their actual expenses + and capital allowances are below the input allowance but unknown; + gov.simulation.trading_allowance_claimant_expense_share sets them as + a share of it, between 0 and 1, by default all of it. They keep the + allowance while it is above nil and at least those expenses, which + keeps their current losses given up, and the deduction is the + allowance's excess over the input allowance, capped at profit. Below + those expenses they deduct them instead: ordinary treatment, which + leaves a supplied current loss claimable. So a cut adds the input + allowance less the larger of the new allowance and those expenses, + by default nothing, the lower bound of its yield. The deduction is + negative where it gives back capital allowances or charges what a cut + adds. Their receipts take up the allowance before income not + otherwise charged, as other receipts do. - A non-claimant does not elect for partial relief, which would be a change in behaviour after a raise, so takes ordinary treatment unless their relevant income is within the allowance: full relief needs no @@ -128,9 +143,10 @@ def trading_allowance_reliefs(person, period, parameters): input_allowance = parameters( f"{_data_year(person, period)}-04-30" ).baseline.gov.hmrc.income_tax.allowances.trading_allowance - claimant_expenses = ( - p.gov.simulation.trading_allowance_claimant_expense_share * input_allowance - ) + # A claimant's expenses are neither negative nor above the allowance + # they chose instead. + expense_share = p.gov.simulation.trading_allowance_claimant_expense_share + claimant_expenses = min_(max_(expense_share, 0), 1) * input_allowance status = person("trading_allowance_claim_status", period) profit = person("self_employment_income", period) receipts = person("self_employment_gross_receipts", period) @@ -204,10 +220,12 @@ def trading_allowance_reliefs(person, period, parameters): ~trading & (miscellaneous_income <= allowance), ) ordinary = (non_claimant & ~within_allowance) | (current_loss > allowance_saving) - # A claimant's election is their claim, not this comparison. + # A claimant's election is their claim, not this comparison: they keep + # the allowance while it covers their expenses, and otherwise deduct + # those, which claimant_relief already charges. ordinary = ordinary & ~claimant return ( where(ordinary, 0, trade_relief), where(ordinary, 0, miscellaneous_relief), - claimant & (allowance > 0), + claimant & (allowance > 0) & (allowance >= claimant_expenses), ) 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 78a1219fce..ba44f435e1 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 @@ -21,9 +21,9 @@ class trading_allowance_deduction(Variable): "inconsistent and treated as unknown. For a claimant " "(trading_allowance_claim_status) with a profit, the profit already " "deducts the allowance legislated for the year the input data were " - "measured (without a dataset, the year simulated, even for inputs " - "carried from an earlier year: a known limitation), so their " - "receipts are their profit plus it, whatever " + "measured (a dataset's first year, or the latest year a situation " + "supplied the status for), so their receipts are their profit plus " + "it, whatever " "receipts are supplied, and the deduction is the allowance's excess " "over it, capped at profit. It also gives back any capital allowances " "supplied for them, which partial relief replaces, so it can be " @@ -32,8 +32,10 @@ class trading_allowance_deduction(Variable): "(by default none). A non-claimant gets the deduction only through " "full relief, where supplied receipts show relevant income within the " "allowance: electing for partial relief after a raise is a change in " - "behaviour. A claimant's claim is their election; for everyone else " - "the model compares the raw loss " + "behaviour. A claimant's claim is their election: they keep the " + "allowance while it is above nil and at least the expenses that " + "share sets, and otherwise take ordinary treatment. For everyone " + "else 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, " "including its relief on miscellaneous income. " diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py index c3b994932b..66726bd6b1 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py @@ -17,9 +17,11 @@ class uses_trading_allowance(Variable): "ordinary treatment and claims it: within the allowance by electing " "out of full relief (s. 783AL), above it by not electing for partial " "relief (s. 783AM). A claimant in the input data " - "(trading_allowance_claim_status) takes it at any allowance above " - "nil, even where it relieves nothing beyond their input profit: " - "their claim was the election, and it gave up their current losses." + "(trading_allowance_claim_status) takes it, even where it relieves " + "nothing beyond their input profit, while it is above nil and at " + "least their expenses: their claim was the election, and it gave up " + "their current losses. Below their expenses they deduct those " + "instead, which is ordinary treatment." ) definition_period = YEAR reference = [ diff --git a/policyengine_uk/variables/input/trading_allowance_claim_status.py b/policyengine_uk/variables/input/trading_allowance_claim_status.py index 9a9ce58148..a666ed2212 100644 --- a/policyengine_uk/variables/input/trading_allowance_claim_status.py +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -18,16 +18,15 @@ class trading_allowance_claim_status(Variable): "their input self-employment profit was measured. Survey data record " "profit, not gross receipts (turnover), and this says which receipts " "lie behind it, so that a change to the allowance reaches the right " - "people. The year is the dataset's first year, which the engine " - "projects to later years, or without a dataset the year simulated; " - "the model reads the allowance legislated for it, which reforms do " - "not move. Known limitation: without a dataset the year simulated " - "stands in even for inputs supplied for an earlier year and carried " - "forward, so a claimant whose inputs are for 2025-26, simulated in " - "2027-28, is read as net of 2027-28's £3,000 allowance and gets " - "nothing from the raise from £1,000. To read inputs as measured in " - "an earlier year, build a dataset for that year. The status " - "describes a positive profit: a person with no " + "people. For microdata the year is the dataset's first year, which " + "the engine projects to later years; a dataset cannot declare " + "another, so a file saved from a later projected year is read as " + "measured in its first year. In a situation it is the latest year, " + "up to the one simulated, for which this status was supplied, so " + "inputs carried from an earlier year keep that year's allowance, " + "and one year serves everyone in the situation. The model reads the " + "allowance legislated for that year, which reforms do not move. The " + "status describes a positive profit: a person with no " "profit has no trade receipts for it to describe, and a claimant is " "then read as UNKNOWN. CLAIMANT: the profit is receipts less that " "allowance, as on a return claiming partial relief (ITTOIA 2005 s. " @@ -35,10 +34,13 @@ class trading_allowance_claim_status(Variable): "self_employment_gross_receipts says. Partial relief replaces " "expenses and capital allowances, so capital_allowances supplied for " "a claimant are not deducted, and their claim was the election (s. " - "783AM), so they keep the allowance, and give up current trading " - "losses, at any allowance above nil. " - "gov.simulation.trading_allowance_claimant_expense_share sets what a " - "cut adds to their profit. Flag only profits net of the allowance: a " + "783AM). Their actual expenses and capital allowances, below that " + "allowance but unknown, are " + "gov.simulation.trading_allowance_claimant_expense_share of it: they " + "keep the allowance, and give up current trading losses, while it is " + "above nil and at least those expenses, and otherwise deduct them, " + "which leaves a current loss claimable. Flag only profits net of the " + "allowance: a " "person whose receipts were within it was fully relieved without a " "claim, and their profit, if reported, is not net of it; leave them " "UNKNOWN. NON_CLAIMANT: the profit is receipts less actual expenses, " From 2435155028ce032a2bd9fbc26e611553c69849e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:47:24 +0000 Subject: [PATCH 5/9] Address third review: core datasets, profit's year, claimant election _data_year checks the dataset's type, so a policyengine-core Dataset, which loads any attribute it is asked for, no longer crashes; data without years take the earliest year the profit was supplied for. In a situation the data year is now the latest year, up to the one simulated, for which self_employment_income was supplied, which replaces the status in SUPPLIED_INPUT_VARIABLES. Claimants make the same election as everyone else, against their own options: the allowance unless its saving over deducting their expenses is negative or smaller than a supplied current loss, so income tax no longer jumps as the allowance or the loss moves. Their expenses are the share of the input allowance in pennies, or their capital allowances if larger, capped at the input allowance, and their figures are computed in float64 so the two options tie exactly where the allowance equals those expenses. Tests: YAML cases for claimants with losses, the switch, capital allowances in the choice and the penny tie; data-year tests for the profit's year and the year filter; the property reference weighs a claimant's loss; a deterministic sweep of ties. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2764.added.md | 2 +- ...ding_allowance_claimant_expense_share.yaml | 22 ++- .../trading_allowance_claim_status.yaml | 140 +++++++++++--- ...rading_allowance_claim_status_data_year.py | 66 +++++-- ...ading_allowance_claim_status_properties.py | 106 ++++++++--- policyengine_uk/utils/supplied_inputs.py | 2 +- policyengine_uk/utils/trading_allowance.py | 172 ++++++++++++------ .../allowances/trading_allowance_deduction.py | 14 +- .../allowances/uses_trading_allowance.py | 9 +- .../input/trading_allowance_claim_status.py | 28 +-- 10 files changed, 396 insertions(+), 165 deletions(-) diff --git a/changelog.d/2764.added.md b/changelog.d/2764.added.md index d3abf0391a..99bc432be9 100644 --- a/changelog.d/2764.added.md +++ b/changelog.d/2764.added.md @@ -1 +1 @@ -Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the data were measured (a dataset's first year, or the latest year a situation supplied the status for), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1): they keep the allowance, giving up current losses, while it is above nil and at least those expenses, and otherwise deduct them, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. +Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the profit was measured (a dataset's first year, or in a situation the latest year, up to the one simulated, for which the profit was supplied), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1), or their capital allowances if larger: like everyone else, they keep the allowance unless deducting those expenses, or a supplied current loss, is worth more, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. diff --git a/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml b/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml index 6e1a7a6f41..87d7267c53 100644 --- a/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml +++ b/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml @@ -1,15 +1,17 @@ description: >- Actual expenses and capital allowances of a trading allowance claimant, as a - share of the allowance their input profit already deducts. It sets how much - a cut below that allowance adds to the taxable profit of people the input - data mark as claimants (trading_allowance_claim_status). A claimant keeps - claiming while the allowance is at least these expenses, and otherwise - deducts them instead, which is ordinary treatment and leaves a supplied - current loss claimable, so a cut adds the input allowance less the larger of - the new allowance and these expenses. The share is between 0 and 1, and a - value outside is taken as the nearer bound: expenses cannot be negative, and - above the allowance the claimant would have deducted them instead. An - allowance at or above the input allowance is unaffected. + share of the allowance their input profit already deducts; capital + allowances supplied for the claimant count where larger. It sets how much a + cut below that allowance adds to the taxable profit of people the input data + mark as claimants (trading_allowance_claim_status). A claimant keeps + claiming unless deducting these expenses instead, which is ordinary + treatment and lets a supplied current loss be claimed, is worth more, so + with no loss a cut adds the input allowance less the larger of the new + allowance and these expenses. The share is between 0 and 1, and a value + outside is taken as the nearer bound: expenses cannot be negative, and above + the allowance the claimant would have deducted them instead. An allowance at + or above the input allowance is unaffected for a claimant with no current + loss. # A claimant's expenses and capital allowances are below the allowance they # claim, or they would deduct them instead, but no survey records them. With # a share of 1, the default, they equal it, so a cut leaves a claimant's diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml index 79f2707d09..0b12fc7860 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml @@ -1,16 +1,17 @@ # trading_allowance_claim_status says which receipts lie behind a person's # input profit (ITTOIA 2005 Part 6A). The input allowance is the allowance -# legislated for the year the data were measured: a dataset's first year, or -# in a situation like these the latest year the status was supplied for, here -# the year simulated. Reforms do not move it. +# legislated for the year the profit was measured: a dataset's first year, or +# in a situation like these the latest year, up to the one simulated, for +# which the profit was supplied, here the year simulated. Reforms do not move +# it. # - Claimant: the profit is receipts less the input allowance, as under # partial relief (s. 783AI), so receipts are profit + input allowance and # the deduction at allowance A is min(profit, A - input allowance). Partial # relief replaces capital allowances, so a claimant's are not deducted. -# Their claim was the election (s. 783AM), so they keep the allowance, and -# give up current losses, while A is above nil and at least their expenses -# (gov.simulation.trading_allowance_claimant_expense_share of the input -# allowance, by default all of it); below those they deduct them instead. +# Their expenses are gov.simulation.trading_allowance_claimant_expense_share +# of the input allowance (by default all of it), or their capital +# allowances if larger. They keep the allowance unless their expenses, or +# a supplied current loss, are worth more (s. 783AM). # - Non-claimant: no election for partial relief; full relief needs none # (s. 783AE) and applies where supplied receipts, or casual income alone # with no profit, are within the allowance. @@ -456,7 +457,7 @@ # (20,000 - 12,570) x 20% income_tax: 1_486 -- name: A claimant gives up a current loss at the input allowance. +- name: At the input allowance a claimant claims a current loss instead. period: 2026 absolute_error_margin: 0.01 input: @@ -465,15 +466,17 @@ trading_loss: 1_500 trading_allowance_claim_status: CLAIMANT output: - # Their claim made the loss nil (ss. 783AF and 783AI). + # By default their expenses are the £1,000 allowance, so the allowance + # saves nothing over them and the loss is worth more (s. 783AM). Their + # claim and the loss contradict each other; the election settles it. trading_allowance_deduction: 0 - uses_trading_allowance: true - trade_loss_relief_against_general_income: 0 - ni_class_4_trading_loss: 0 - # (30,000 + 2,500 - 12,570) x 20% - income_tax: 3_986 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 1_500 + ni_class_4_trading_loss: 1_500 + # (30,000 + 2,500 - 1,500 - 12,570) x 20% + income_tax: 3_686 -- name: A raise to £3,000 gives a claimant with a current loss its full £2,000. +- name: A raise to £3,000 beats a claimant's smaller current loss. period: 2026 absolute_error_margin: 0.01 input: @@ -483,13 +486,15 @@ trading_loss: 1_500 trading_allowance_claim_status: CLAIMANT output: + # The allowance saves £2,000, more than the £1,500 loss. trading_allowance_deduction: 2_000 uses_trading_allowance: true trade_loss_relief_against_general_income: 0 - # (30,000 + 500 - 12,570) x 20%, £400 less than at £1,000. + ni_class_4_trading_loss: 0 + # (30,000 + 500 - 12,570) x 20%, £100 less than at £1,000. income_tax: 3_586 -- name: A claimant keeps the allowance against a current loss larger than its saving. +- name: A claimant's current loss larger than the allowance's saving is claimed instead. period: 2026 absolute_error_margin: 0.01 input: @@ -499,13 +504,13 @@ trading_loss: 2_500 trading_allowance_claim_status: CLAIMANT output: - # They chose the allowance over this loss at £1,000, and a raise only - # makes it better. - trading_allowance_deduction: 2_000 - uses_trading_allowance: true - trade_loss_relief_against_general_income: 0 - ni_class_4_trading_loss: 0 - income_tax: 3_586 + # The allowance saves £2,000, less than the £2,500 loss. + trading_allowance_deduction: 0 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 2_500 + ni_class_4_trading_loss: 2_500 + # (30,000 + 2,500 - 2,500 - 12,570) x 20% + income_tax: 3_486 - name: Abolishing the allowance lets a claimant claim a current loss. period: 2026 @@ -542,6 +547,93 @@ # (30,000 + 2,500 - 1,500 - 12,570) x 20% income_tax: 3_686 +- name: With £500 of expenses and a £1,500 loss, an allowance of £499 leaves the loss claimed. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 499 + gov.simulation.trading_allowance_claimant_expense_share: 0.5 + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 1_500 + trading_allowance_claim_status: CLAIMANT + output: + # They deduct their £500 of expenses: 3,500 - 500 = 3,000, less the loss. + taxable_self_employment_income: 3_000 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 1_500 + # (30,000 + 3,000 - 1,500 - 12,570) x 20% + income_tax: 3_786 + +- name: With £500 of expenses and a £1,500 loss, an allowance of £500 changes nothing. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 500 + gov.simulation.trading_allowance_claimant_expense_share: 0.5 + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 1_500 + trading_allowance_claim_status: CLAIMANT + output: + # The allowance equals their expenses and saves nothing, so the loss is + # still claimed and income tax does not jump as it crosses them. + taxable_self_employment_income: 3_000 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 1_500 + income_tax: 3_786 + +- name: With £500 of expenses and a £1,500 loss, an allowance of £2,000 is worth the loss. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 2_000 + gov.simulation.trading_allowance_claimant_expense_share: 0.5 + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 1_500 + trading_allowance_claim_status: CLAIMANT + output: + # It saves 2,000 - 500 = 1,500, the loss's worth; ties take the + # allowance: 3,500 - 2,000 = 1,500, the same income as with the loss. + taxable_self_employment_income: 1_500 + uses_trading_allowance: true + trade_loss_relief_against_general_income: 0 + # (30,000 + 1,500 - 12,570) x 20% + income_tax: 3_786 + +- name: A claimant's capital allowances above the share's expenses count as expenses. + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 600 + gov.simulation.trading_allowance_claimant_expense_share: 0.5 + self_employment_income: 2_500 + capital_allowances: 800 + trading_allowance_claim_status: CLAIMANT + output: + # £800 of capital allowances beat a £600 allowance: receipts of 3,500 + # less 800, where keeping the allowance would leave 2,900. + trading_allowance_deduction: -1_000 + taxable_self_employment_income: 2_700 + uses_trading_allowance: false + +- name: An allowance equal to a claimant's expenses keeps the allowance. + period: 2027 + absolute_error_margin: 0 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 1_650 + gov.simulation.trading_allowance_claimant_expense_share: 0.55 + self_employment_income: 600 + trading_allowance_claim_status: CLAIMANT + output: + # 0.55 x 3,000 is £1,650 to the penny (in floating point a hair above), + # equal to the allowance: a tie, which takes the allowance. Receipts + # 3,600 less 1,650. + trading_allowance_deduction: -1_350 + taxable_self_employment_income: 1_950 + uses_trading_allowance: true + - name: A non-claimant claims a current loss the allowance would have taken. period: 2027 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py index bff30416f1..3931ef001a 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py @@ -3,7 +3,7 @@ A claimant's profit already deducts the allowance legislated for that year: a dataset's first year, which Simulation projects to later years, or in a -situation the latest year, up to the one simulated, for which the status was +situation the latest year, up to the one simulated, for which the profit was supplied. These tests build small datasets and situations with earlier inputs, so the data year and the simulated year differ, as they do for survey data projected past the April 2027 raise to £3,000 (Budget 2026, @@ -82,14 +82,18 @@ def test_a_reform_of_the_data_year_leaves_the_input_allowance(): assert deduction(simulation, 2026)[0] == pytest.approx(4_000) -def test_a_raise_never_withdraws_a_claimants_loss_relief(): +def test_a_raise_never_raises_a_claimants_tax_when_they_have_a_loss(): kept = {ALLOWANCE: {str(year): 1_000 for year in range(2027, 2029)}} - law = Simulation(dataset=traders(2025, loss=5_000)) - at_1000 = Simulation(dataset=traders(2025, loss=5_000), reform=kept) - # The claimant's claim gave the loss up at £1,000 and still does at - # £3,000, so the raise only adds the £2,000 deduction. - tax = law.calculate("income_tax", 2027)[0] - assert tax == pytest.approx(at_1000.calculate("income_tax", 2027)[0] - 400, abs=1) + law = Simulation(dataset=traders(2025, loss=1_000)) + at_1000 = Simulation(dataset=traders(2025, loss=1_000), reform=kept) + # At £1,000 the allowance saves the claimant nothing over their + # expenses, so they claim the loss. At £3,000 it saves £2,000, more than + # the loss, so they take it and give the loss up (s. 783AM). + assert not at_1000.calculate("uses_trading_allowance", 2027)[0] + assert law.calculate("uses_trading_allowance", 2027)[0] + assert ( + law.calculate("income_tax", 2027)[0] < at_1000.calculate("income_tax", 2027)[0] + ) def situation(person): @@ -132,22 +136,48 @@ def test_a_situation_reads_carried_inputs_as_measured_in_the_year_supplied(): def test_a_situation_reads_each_year_supplied_as_measured_then(): - # Inputs supplied for 2027 were measured under 2027-28's £3,000, so they - # gain nothing from it; 2026 still reads the 2025 inputs, at £1,000. - simulation = Simulation( + # The 2027 profit was measured under 2027-28's £3,000, so it gains + # nothing from it. 2026 reads the 2025 profit, not the later one: a + # claimant at £1,000, who would gain £2,000 from a raise to £3,000. + person = { + "age": {2025: 40}, + "self_employment_income": {2025: 5_000, 2027: 5_000}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + simulation = Simulation(situation=situation(person)) + assert deduction(simulation, 2027) == [0] + assert deduction(simulation, 2026) == [0] + assert simulation.calculate("uses_trading_allowance", 2026).tolist() == [True] + raised = Simulation( + situation=situation(person), reform={ALLOWANCE: {"2026": 3_000}} + ) + assert deduction(raised, 2026) == pytest.approx([2_000]) + + +def test_a_situation_reads_the_year_of_the_profit_not_of_the_status(): + # A 2027 profit was measured in 2027-28, under £3,000, whatever year the + # status was given for; a 2025 profit carried to 2027 was measured under + # £1,000. + later_profit = Simulation( + situation=situation( + { + "age": {2027: 40}, + "self_employment_income": {2027: 5_000}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + ) + ) + assert deduction(later_profit, 2027) == [0] + earlier_profit = Simulation( situation=situation( { "age": {2025: 40}, - "self_employment_income": {2025: 5_000, 2027: 5_000}, - "trading_allowance_claim_status": { - 2025: "CLAIMANT", - 2027: "CLAIMANT", - }, + "self_employment_income": {2025: 5_000}, + "trading_allowance_claim_status": {2027: "CLAIMANT"}, } ) ) - assert deduction(simulation, 2026) == [0] - assert deduction(simulation, 2027) == [0] + assert deduction(earlier_profit, 2027) == pytest.approx([2_000]) def test_a_raise_never_withdraws_a_situation_claimants_loss_relief(): diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py index 5815459d0b..a5358ebbf3 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py @@ -15,9 +15,10 @@ (s. 783AM), which a non-claimant has not made and a claimant has; ordinary treatment deducts actual expenses and capital allowances, which for a claimant partial relief replaced and are taken as a share of the input -allowance (gov.simulation.trading_allowance_claimant_expense_share), between -none and all of it. A claimant keeps the allowance while it does at least as -well for the trade as those expenses. +allowance (gov.simulation.trading_allowance_claimant_expense_share), or their +capital allowances if larger, never above the input allowance. Everyone who +can take the allowance takes it unless a supplied current loss is worth more +than its saving; a claimant's saving is over deducting their expenses. Invariants, each checked over many generated people in one reusable simulation per allowance level and expense share: @@ -174,35 +175,41 @@ def reference(r, allowance, share): [profit + INPUT_ALLOWANCE, r["receipts"], 0.0, np.inf], profit, ) - # Ordinary treatment deducts actual expenses and capital allowances; a - # claimant's are a share of the input allowance, between none and all of - # it, since they chose the allowance instead. - claimant_expenses = min(max(share, 0), 1) * INPUT_ALLOWANCE + # Ordinary treatment deducts actual expenses and capital allowances. A + # claimant's are a share of the input allowance, in pennies, or their + # capital allowances if larger, but never above the allowance they chose + # instead. + share_expenses = round(min(max(share, 0), 1) * INPUT_ALLOWANCE, 2) + claimant_expenses = np.minimum(np.maximum(share_expenses, capital), INPUT_ALLOWANCE) ordinary = np.where(claimant, receipts - claimant_expenses, profit - capital) # Full relief (no election) or partial relief, trades first (s. 783AK). full = receipts + chapter_8 <= allowance with_allowance = np.where(full, 0, np.maximum(receipts - allowance, 0)) chapter_8_relief = np.minimum(chapter_8, np.maximum(allowance - receipts, 0)) - # Partial relief needs an election, which a non-claimant has not made. + # Partial relief needs an election, which a non-claimant has not made. A + # claimant's option is the allowance's own figure; anyone else's keeps + # actual deductions where those do better. available = full | ~non_claimant - trade = np.where(available, np.minimum(with_allowance, ordinary), ordinary) + trade = np.where( + available, + np.where(claimant, with_allowance, np.minimum(with_allowance, ordinary)), + ordinary, + ) chapter_8_relief = np.where(available, chapter_8_relief, 0) - # A claimant's claim is their election: they keep the allowance while - # there is one and it does at least as well for the trade as deducting - # their expenses, and otherwise take ordinary treatment. A current loss - # does not enter: their claim set it aside. - claimant_takes = claimant & (allowance > 0) & (with_allowance <= ordinary) - chapter_8_relief = np.where(claimant & ~claimant_takes, 0, chapter_8_relief) - # Anyone else claims a current loss larger than the allowance's saving - # instead (ss. 783AL, 783AM). + # Ordinary treatment wins where a supplied current loss is worth more + # than the allowance's saving (ss. 783AL, 783AM). A claimant's saving is + # over deducting their expenses, negative where those do better, so they + # then take ordinary treatment with no loss at all. saving = ( np.maximum(ordinary - brought_forward, 0) - np.maximum(trade - brought_forward, 0) + chapter_8_relief ) - claims_loss = ~claimant & (loss > saving) - trade = np.where(claims_loss, ordinary, trade) - chapter_8_relief = np.where(claims_loss, 0, chapter_8_relief) + claims_loss = loss > saving + claimant_takes = claimant & (allowance > 0) & ~claims_loss + ordinary_treatment = np.where(claimant, ~claimant_takes, claims_loss) + trade = np.where(ordinary_treatment, ordinary, trade) + chapter_8_relief = np.where(ordinary_treatment, 0, chapter_8_relief) takes_allowance = np.where( claimant, claimant_takes, @@ -210,15 +217,17 @@ def reference(r, allowance, share): ) # The model sums in float32, so where the reference compares a sum with # a threshold it may land on the other side within rounding: a - # non-claimant's relevant income against the allowance, anyone else's - # supplied loss against the saving, and, for whether they take the - # allowance, its advantage on the trade against nil. A claimant's choice - # compares the allowances alone, exactly. + # non-claimant's relevant income against the allowance, a loss against + # the saving (for a claimant with no loss, a saving near nil unless the + # allowance equals their expenses exactly), and, for whether anyone else + # takes the allowance, its advantage on the trade against nil. margin = tolerance(r["receipts"], profit, chapter_8, allowance) finite = np.isfinite(receipts) near_full = non_claimant & finite near_full &= np.abs(np.where(finite, receipts, 0) + chapter_8 - allowance) <= margin - near_loss = ~claimant & (loss > 0) & (np.abs(loss - saving) <= margin) + exact_tie = claimant & (loss == 0) & (allowance == claimant_expenses) + near_loss = (loss > 0) | claimant + near_loss &= (np.abs(loss - saving) <= margin) & ~exact_tie advantage = np.where(np.isfinite(with_allowance), ordinary - with_allowance, -1) return dict( taxable=np.maximum(trade - brought_forward, 0), @@ -301,3 +310,50 @@ def test_a_smaller_expense_share_never_lowers_a_claimants_taxable_profit(sims, d ] for larger_share, smaller_share in zip(taxable, taxable[1:]): assert (smaller_share >= larger_share - tolerance(larger_share)).all() + + +@pytest.mark.parametrize( + "year,allowance,share", + [(2026, 1_000, 1.0), (2026, 500, 0.5), (2027, 1_650, 0.55)], +) +def test_a_claimant_with_no_loss_takes_an_allowance_equal_to_their_expenses( + year, allowance, share +): + """Where the allowance equals a claimant's expenses the two options tie, + and ties take the allowance. Over many profits, capital allowances below + those expenses and losses brought forward, float arithmetic must not tip + the tie: the input allowance is £1,000 in 2026-27 and £3,000 in 2027-28, + and 0.55 x £3,000 is a hair above £1,650 in floating point.""" + rng = np.random.default_rng(2764) + count = 2_000 + expenses = round(share * {2026: 1_000, 2027: 3_000}[year], 2) + people = {f"person_{i}": {"age": {year: 40}} for i in range(count)} + simulation = Simulation( + situation={ + "people": people, + "benunits": {f"b_{i}": {"members": [f"person_{i}"]} for i in range(count)}, + "households": { + f"h_{i}": {"members": [f"person_{i}"]} for i in range(count) + }, + }, + reform={ + "gov.hmrc.income_tax.allowances.trading_allowance": {str(year): allowance}, + "gov.simulation.trading_allowance_claimant_expense_share": { + str(year): share + }, + }, + ) + some = rng.random(count) < 0.5 + simulation.set_input( + "self_employment_income", year, np.round(rng.uniform(0.01, 20_000, count), 2) + ) + simulation.set_input( + "capital_allowances", year, np.round(rng.uniform(0, expenses, count), 2) * some + ) + simulation.set_input( + "loss_relief", year, np.round(rng.uniform(0, 5_000, count), 2) * ~some + ) + simulation.set_input( + "trading_allowance_claim_status", year, np.array(["CLAIMANT"] * count) + ) + assert simulation.calculate("uses_trading_allowance", year).all() diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index db65729b02..7a6ac7434a 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -55,6 +55,7 @@ "receives_carer_benefit", "receives_carers_allowance", "receives_carer_support_payment", + "self_employment_income", "bus_fare_spending", "bus_in_london_trips", "current_education", @@ -82,7 +83,6 @@ "ni_epg_full_rate_applies", "tenure_type", "targeted_childcare_entitlement", - "trading_allowance_claim_status", "trading_loss", "uc_carer_element", "uc_LCWRA_element", diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index 5e29d2e637..715569e265 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -9,7 +9,7 @@ come from one decision. """ -from policyengine_core.model_api import max_, min_, where +from policyengine_core.model_api import max_, min_, round_, where from policyengine_uk.utils.data_source import built_from_data from policyengine_uk.utils.supplied_inputs import ( @@ -19,36 +19,44 @@ def _data_year(person, period): - """The year the input data were measured. + """The year the input self-employment profit was measured. - - Microdata: their first year, which the engine projects to later years, - as the State Pension variables read it. The status is stored for every - projected year, so the years it was supplied for say nothing here. + - A UK dataset: its first year, which the engine projects to later + years, as the State Pension variables read it. Its inputs are stored + for every projected year, so the years they were supplied for say + nothing here. + - Other data without years, such as a policyengine-core Dataset: the + earliest year self_employment_income was supplied for, for the same + reason. - A situation: the latest year, up to the one simulated, for which - trading_allowance_claim_status was supplied. Inputs supplied for an - earlier year and carried forward keep that year's allowance: a - claimant given 2025-26 inputs and simulated in 2027-28 deducted £1,000, - and gains from the raise to £3,000. One year serves the whole - situation, so people whose status was supplied for different years - all take the latest. With no status supplied, the year simulated. - - Not handled: a dataset cannot declare the year its incomes were measured - in, so a file saved from a later projected year is read as measured in - its first year. + self_employment_income was supplied. A profit carried from an earlier + year keeps that year's allowance: a 2025-26 profit simulated in + 2027-28 deducted £1,000, and gains from the raise to £3,000. The + builder stores a year's inputs for everyone in the situation, giving + anyone not set the default, so one year serves them all. + With no profit supplied, the year simulated. + + Not handled: data cannot declare the year their incomes were measured + in, so a file saved from a later projected year, or a situation saved + over several years, is read as measured in its first year. """ + from policyengine_uk.data import UKMultiYearDataset + simulation = person.simulation + supplied = supplied_input_periods(person, "self_employment_income") if built_from_data(simulation): - years = getattr(getattr(simulation, "dataset", None), "years", None) - if years: - return min(years) - supplied = [ + # Checked by type: a policyengine-core Dataset loads any attribute + # it is asked for as a table, so it cannot be asked for years. + dataset = getattr(simulation, "dataset", None) + if isinstance(dataset, UKMultiYearDataset): + return min(dataset.years) + return (supplied[0] if supplied else period).start.year + earlier = [ supplied_period - for supplied_period in supplied_input_periods( - person, "trading_allowance_claim_status" - ) + for supplied_period in supplied if supplied_period.start <= period.start ] - return (supplied[-1] if supplied else period).start.year + return (earlier[-1] if earlier else period).start.year def trading_allowance_reliefs(person, period, parameters): @@ -102,25 +110,30 @@ def trading_allowance_reliefs(person, period, parameters): profit. A person with no profit has no trade receipts for it to describe, and a claimant is then read as unknown. - A claimant's profit is their receipts less the allowance legislated - for the year the input data were measured (the input allowance, see - _data_year), which reforms do not move. Their claim was the election - for partial relief (s. 783AM), and it replaced their expenses and + for the year the profit was measured (the input allowance, see + _data_year), which reforms do not move. Their claim was an election + for partial relief (s. 783AM), which replaced their expenses and capital allowances (s. 783AI): capital_allowances supplied for them are not deducted, and their receipts, profit plus the input allowance, take precedence over gross receipts. Their actual expenses - and capital allowances are below the input allowance but unknown; - gov.simulation.trading_allowance_claimant_expense_share sets them as - a share of it, between 0 and 1, by default all of it. They keep the - allowance while it is above nil and at least those expenses, which - keeps their current losses given up, and the deduction is the - allowance's excess over the input allowance, capped at profit. Below - those expenses they deduct them instead: ordinary treatment, which - leaves a supplied current loss claimable. So a cut adds the input - allowance less the larger of the new allowance and those expenses, - by default nothing, the lower bound of its yield. The deduction is - negative where it gives back capital allowances or charges what a cut - adds. Their receipts take up the allowance before income not - otherwise charged, as other receipts do. + and capital allowances are below the input allowance but unknown: + gov.simulation.trading_allowance_claimant_expense_share of it, between + 0 and 1 and by default all of it, in pennies, or the capital + allowances supplied for them where those are larger. Their options + are the allowance, which leaves receipts less the allowance (nil + within it), and ordinary treatment, which leaves receipts less those + expenses and lets a supplied current loss be claimed. They make the + same election as everyone else: the allowance, unless its saving over + their expenses is negative or smaller than such a loss. So under the + default share a claimant with no loss keeps the allowance at or above + the input allowance, where the deduction is its excess over the input + allowance, capped at profit, and a cut leaves their profit as + reported, the lower bound of its yield; a claimant with a loss claims + it until the allowance saves more. The tax they pay never jumps as + the allowance or the loss moves. The deduction is negative where it + gives back capital allowances or charges what a cut adds. Their + receipts take up the allowance before income not otherwise charged, + as other receipts do. - A non-claimant does not elect for partial relief, which would be a change in behaviour after a raise, so takes ordinary treatment unless their relevant income is within the allowance: full relief needs no @@ -143,14 +156,26 @@ def trading_allowance_reliefs(person, period, parameters): input_allowance = parameters( f"{_data_year(person, period)}-04-30" ).baseline.gov.hmrc.income_tax.allowances.trading_allowance - # A claimant's expenses are neither negative nor above the allowance - # they chose instead. - expense_share = p.gov.simulation.trading_allowance_claimant_expense_share - claimant_expenses = min_(max_(expense_share, 0), 1) * input_allowance status = person("trading_allowance_claim_status", period) profit = person("self_employment_income", period) receipts = person("self_employment_gross_receipts", period) capital_allowances = person("capital_allowances", period) + # A claimant's actual expenses and capital allowances: the share of the + # input allowance, in pennies, or the capital allowances supplied for + # them where those are larger, but never above the allowance they chose + # instead. + expense_share = p.gov.simulation.trading_allowance_claimant_expense_share + # A claimant's figures are all in float64, so that the two options tie + # exactly where the allowance equals their expenses. + claimant_profit = profit.astype(float) + claimant_capital_allowances = capital_allowances.astype(float) + claimant_expenses = min_( + max_( + round_(min_(max_(expense_share, 0), 1) * input_allowance, 2), + claimant_capital_allowances, + ), + input_allowance, + ) trading = profit > 0 claimant = trading & (status == status.possible_values.CLAIMANT) non_claimant = status == status.possible_values.NON_CLAIMANT @@ -173,14 +198,18 @@ def trading_allowance_reliefs(person, period, parameters): receipts, where(profit <= allowance, max_(profit, 0), allowance), ) - # A claimant keeps the allowance while it is at least their expenses, - # and otherwise deducts those; either way the deduction gives back the - # capital allowances deducted separately. - claimant_relief = ( - min_(profit, max_(allowance, claimant_expenses) - input_allowance) - - capital_allowances + # A claimant's receipts are their profit plus the input allowance. Their + # options are the allowance, which leaves receipts less the allowance, + # nil within it, and ordinary treatment, which leaves receipts less their + # expenses. Each deduction is from the profit and gives back the capital + # allowances deducted separately, which either option replaces. + claimant_allowance_relief = ( + min_(claimant_profit, allowance - input_allowance) - claimant_capital_allowances + ) + claimant_ordinary_relief = ( + min_(claimant_profit, claimant_expenses - input_allowance) + - claimant_capital_allowances ) - trade_relief = where(claimant, claimant_relief, trade_relief) trade_receipts = where(claimant, profit + input_allowance, trade_receipts) miscellaneous_income = min_( @@ -200,12 +229,21 @@ def trading_allowance_reliefs(person, period, parameters): # 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) - ) + loss_relief = person("loss_relief", period) + profit_after_other_deductions = max_(0, profit - capital_allowances - loss_relief) allowance_saving = ( min_(trade_relief, profit_after_other_deductions) + miscellaneous_relief ) + # A claimant's saving is the allowance's over their own ordinary + # treatment, after losses brought forward. + claimant_before_relief = ( + claimant_profit - claimant_capital_allowances - loss_relief.astype(float) + ) + claimant_saving = ( + max_(claimant_before_relief - claimant_ordinary_relief, 0) + - max_(claimant_before_relief - claimant_allowance_relief, 0) + + miscellaneous_relief + ) # 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) @@ -220,12 +258,26 @@ def trading_allowance_reliefs(person, period, parameters): ~trading & (miscellaneous_income <= allowance), ) ordinary = (non_claimant & ~within_allowance) | (current_loss > allowance_saving) - # A claimant's election is their claim, not this comparison: they keep - # the allowance while it covers their expenses, and otherwise deduct - # those, which claimant_relief already charges. - ordinary = ordinary & ~claimant + # A claimant makes the same election against their own options: the + # allowance unless it does worse than their expenses, or than a larger + # supplied current loss. Ties take the allowance. + claimant_takes_allowance = ( + claimant & (allowance > 0) & ~(current_loss > claimant_saving) + ) return ( - where(ordinary, 0, trade_relief), - where(ordinary, 0, miscellaneous_relief), - claimant & (allowance > 0) & (allowance >= claimant_expenses), + where( + claimant, + where( + claimant_takes_allowance, + claimant_allowance_relief, + claimant_ordinary_relief, + ), + where(ordinary, 0, trade_relief), + ), + where( + claimant, + where(claimant_takes_allowance, miscellaneous_relief, 0), + where(ordinary, 0, miscellaneous_relief), + ), + claimant_takes_allowance, ) 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 ba44f435e1..46c397ca5f 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 @@ -21,9 +21,9 @@ class trading_allowance_deduction(Variable): "inconsistent and treated as unknown. For a claimant " "(trading_allowance_claim_status) with a profit, the profit already " "deducts the allowance legislated for the year the input data were " - "measured (a dataset's first year, or the latest year a situation " - "supplied the status for), so their receipts are their profit plus " - "it, whatever " + "measured (a dataset's first year, or in a situation the latest " + "year, up to the one simulated, for which the profit was supplied), " + "so their receipts are their profit plus it, whatever " "receipts are supplied, and the deduction is the allowance's excess " "over it, capped at profit. It also gives back any capital allowances " "supplied for them, which partial relief replaces, so it can be " @@ -32,10 +32,10 @@ class trading_allowance_deduction(Variable): "(by default none). A non-claimant gets the deduction only through " "full relief, where supplied receipts show relevant income within the " "allowance: electing for partial relief after a raise is a change in " - "behaviour. A claimant's claim is their election: they keep the " - "allowance while it is above nil and at least the expenses that " - "share sets, and otherwise take ordinary treatment. For everyone " - "else the model compares the raw loss " + "behaviour. A claimant's ordinary treatment deducts the expenses " + "that share sets, or their capital allowances if larger, and their " + "allowance saving is over those. For everyone, claimants included, " + "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, " "including its relief on miscellaneous income. " diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py index 66726bd6b1..390e5a8294 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py @@ -17,11 +17,10 @@ class uses_trading_allowance(Variable): "ordinary treatment and claims it: within the allowance by electing " "out of full relief (s. 783AL), above it by not electing for partial " "relief (s. 783AM). A claimant in the input data " - "(trading_allowance_claim_status) takes it, even where it relieves " - "nothing beyond their input profit, while it is above nil and at " - "least their expenses: their claim was the election, and it gave up " - "their current losses. Below their expenses they deduct those " - "instead, which is ordinary treatment." + "(trading_allowance_claim_status) counts whenever they take it, even " + "where it relieves nothing beyond their input profit. By default, " + "with no current loss, they take it at any allowance at or above the " + "one their profit already deducts." ) definition_period = YEAR reference = [ diff --git a/policyengine_uk/variables/input/trading_allowance_claim_status.py b/policyengine_uk/variables/input/trading_allowance_claim_status.py index a666ed2212..a824c3f579 100644 --- a/policyengine_uk/variables/input/trading_allowance_claim_status.py +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -19,12 +19,12 @@ class trading_allowance_claim_status(Variable): "profit, not gross receipts (turnover), and this says which receipts " "lie behind it, so that a change to the allowance reaches the right " "people. For microdata the year is the dataset's first year, which " - "the engine projects to later years; a dataset cannot declare " - "another, so a file saved from a later projected year is read as " - "measured in its first year. In a situation it is the latest year, " - "up to the one simulated, for which this status was supplied, so " - "inputs carried from an earlier year keep that year's allowance, " - "and one year serves everyone in the situation. The model reads the " + "the engine projects to later years; data cannot declare another, so " + "a file saved from a later projected year is read as measured in its " + "first year. In a situation it is the latest year, up to the one " + "simulated, for which self_employment_income was supplied, so a " + "profit carried from an earlier year keeps that year's allowance, " + "whatever year this status was given for. The model reads the " "allowance legislated for that year, which reforms do not move. The " "status describes a positive profit: a person with no " "profit has no trade receipts for it to describe, and a claimant is " @@ -33,14 +33,14 @@ class trading_allowance_claim_status(Variable): "783AI), so the receipts are that profit plus the allowance, whatever " "self_employment_gross_receipts says. Partial relief replaces " "expenses and capital allowances, so capital_allowances supplied for " - "a claimant are not deducted, and their claim was the election (s. " - "783AM). Their actual expenses and capital allowances, below that " - "allowance but unknown, are " - "gov.simulation.trading_allowance_claimant_expense_share of it: they " - "keep the allowance, and give up current trading losses, while it is " - "above nil and at least those expenses, and otherwise deduct them, " - "which leaves a current loss claimable. Flag only profits net of the " - "allowance: a " + "a claimant are not deducted. Their actual expenses and capital " + "allowances, below that allowance but unknown, are " + "gov.simulation.trading_allowance_claimant_expense_share of it, or " + "the capital allowances supplied if larger. Like everyone else they " + "keep the allowance, and give up current trading losses, unless " + "deducting those expenses (ordinary treatment, s. 783AM), with any " + "supplied current loss claimed, is worth more. Flag only profits net " + "of the allowance: a " "person whose receipts were within it was fully relieved without a " "claim, and their profit, if reported, is not net of it; leave them " "UNKNOWN. NON_CLAIMANT: the profit is receipts less actual expenses, " From 7b25254f44d49667cb958f57103628802741551d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:58:58 +0000 Subject: [PATCH 6/9] Address fourth review and cut the claim-status tests' CI time A claimant keeps the allowance only where it covers their expenses, so losses brought forward that absorb both options no longer tie them onto an allowance below those expenses, which charged Class 4 more. Simulation.build_from_situation records the years the situation gave a self-employment profit for, and _data_year reads that record, so a later write of the profit (a modifier, a derivative) no longer moves a situation's data year. Clones and branches carry it. Capital allowances are rounded to pennies before they set a claimant's expenses. Tests: the property test's four hypothesis tests are now one, which runs each reformed simulation once per draw; each worker rebuilt the 15 simulations for every test before, which took Python shards 2 and 3 past the job limit. The tie sweeps use 500 claimants. New tests cover the core-data rule, a situation claimant's supplied loss, a derivative and a no-op modifier, losses brought forward under a cut and capital-allowance ties. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2764.added.md | 2 +- ...ding_allowance_claimant_expense_share.yaml | 8 +- policyengine_uk/simulation.py | 14 +++ .../trading_allowance_claim_status.yaml | 34 +++++ ...rading_allowance_claim_status_data_year.py | 116 +++++++++++++++++- ...ading_allowance_claim_status_properties.py | 70 +++++------ policyengine_uk/utils/trading_allowance.py | 51 +++++--- .../input/trading_allowance_claim_status.py | 8 +- 8 files changed, 238 insertions(+), 65 deletions(-) diff --git a/changelog.d/2764.added.md b/changelog.d/2764.added.md index 99bc432be9..7536a8e823 100644 --- a/changelog.d/2764.added.md +++ b/changelog.d/2764.added.md @@ -1 +1 @@ -Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the profit was measured (a dataset's first year, or in a situation the latest year, up to the one simulated, for which the profit was supplied), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1), or their capital allowances if larger: like everyone else, they keep the allowance unless deducting those expenses, or a supplied current loss, is worth more, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. +Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the profit was measured (a dataset's first year, or in a situation the latest year, up to the one simulated, for which the profit was supplied), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1), or their capital allowances if larger, never more than that allowance: like everyone else, they keep the allowance where it covers those expenses, unless deducting them, or a supplied current loss, is worth more, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. diff --git a/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml b/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml index 87d7267c53..5d2478c438 100644 --- a/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml +++ b/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml @@ -16,9 +16,11 @@ description: >- # claim, or they would deduct them instead, but no survey records them. With # a share of 1, the default, they equal it, so a cut leaves a claimant's # taxable profit as reported: the lower bound of its yield. With 0 they have -# none, so every claimant keeps claiming and a cut adds its full amount: the -# upper bound. 1 is a convention, not an estimate, kept so that the model -# books no yield it cannot support (PolicyEngine/policyengine-uk#2764). +# none beyond any capital allowances supplied for them, so a claimant with +# no capital allowances and no current loss keeps claiming and a cut adds its +# full amount: the upper bound. 1 is a convention, not an estimate, kept so +# that the model books no yield it cannot support +# (PolicyEngine/policyengine-uk#2764). values: 2005-04-01: 1 metadata: diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index e41093f979..946739acc1 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -95,6 +95,7 @@ forget_replaced_model_values, keep_population_records, model_value_input_records, + supplied_input_periods, ) from policyengine_uk.reforms import create_structural_reforms_from_parameters from policyengine_uk.parameters.gov.simulation.labour_supply_responses.aliases import ( @@ -686,6 +687,7 @@ def clone(self, *args, **kwargs) -> "Simulation": clone._savings_income_projection = getattr( self, "_savings_income_projection", None ) + clone._situation_profit_years = getattr(self, "_situation_profit_years", None) return clone def _isa_latest_input(self, name, period, *, earlier=False): @@ -1084,6 +1086,17 @@ def build_from_situation(self, situation: Dict) -> None: builder.build_from_dict(self.tax_benefit_system, situation, self) self.has_axes = builder.has_axes self._capture_cgt_supplied_inputs() + # The years the situation gave a self-employment profit for, as + # supplied: the trading allowance reads the year a claimant's profit + # was measured from them (utils/trading_allowance.py, _data_year), so + # a later write of the profit, by a scenario or a derivative, must + # not move it. + self._situation_profit_years = tuple( + period.start.year + for period in supplied_input_periods( + self.populations["person"], "self_employment_income" + ) + ) self.dataset = UKSingleYearDataset.from_simulation( self, fiscal_year=self.default_input_period @@ -1474,6 +1487,7 @@ def _start_new_population(self, built_from_dataset: bool) -> None: self._cgt_warned_unused = False self._isa_income_contracts = {} self._savings_income_projection = None + self._situation_profit_years = None def build_from_ids( self, diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml index 0b12fc7860..919d6f033e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml @@ -602,6 +602,40 @@ # (30,000 + 1,500 - 12,570) x 20% income_tax: 3_786 +- name: Losses brought forward do not keep a claimant on the allowance below their expenses. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 1 + self_employment_income: 20_000 + loss_relief: 25_000 + trading_allowance_claim_status: CLAIMANT + output: + # The £25,000 of losses absorb either option, so income tax cannot tell + # them apart; a £1 allowance does not cover the £1,000 of expenses, so + # they deduct those, and Class 4, which does not see these losses, + # charges the profit as reported. + trading_allowance_deduction: 0 + uses_trading_allowance: false + ni_class_4_profits_before_losses: 20_000 + # (20,000 - 12,570) x 6% + ni_class_4: 445.80 + +- name: An allowance equal to a claimant's capital allowances keeps the allowance. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 726.21 + gov.simulation.trading_allowance_claimant_expense_share: 0 + self_employment_income: 2_500 + capital_allowances: 726.21 + trading_allowance_claim_status: CLAIMANT + output: + # Their expenses are the £726.21 of capital allowances, to the penny: + # a tie, which takes the allowance. Receipts 3,500 less 726.21. + uses_trading_allowance: true + taxable_self_employment_income: 2_773.79 + - name: A claimant's capital allowances above the share's expenses count as expenses. period: 2026 absolute_error_margin: 0 diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py index 3931ef001a..3693518e12 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py @@ -180,10 +180,12 @@ def test_a_situation_reads_the_year_of_the_profit_not_of_the_status(): assert deduction(earlier_profit, 2027) == pytest.approx([2_000]) -def test_a_raise_never_withdraws_a_situation_claimants_loss_relief(): +def test_a_raise_never_withdraws_a_situation_claimants_carried_loss(): # The situation's inputs are for 2025, so in 2027 the raise to £3,000 - # gives this claimant £2,000. Their claim gave the loss up at £1,000 and - # still does at £3,000, so the raise only lowers their tax. + # gives this claimant £2,000. The 2025 loss carried into 2027 is not a + # supplied 2027 loss, so it does not enter the election, for a claimant + # as for anyone: they take the allowance at £1,000 and at £3,000 alike, + # and the raise only adds its £2,000. person = { "age": {2025: 40}, "employment_income": {2025: 30_000}, @@ -196,3 +198,111 @@ def test_a_raise_never_withdraws_a_situation_claimants_loss_relief(): at_1000 = Simulation(situation=situation(person), reform=kept) tax = law.calculate("income_tax", 2027)[0] assert tax == pytest.approx(at_1000.calculate("income_tax", 2027)[0] - 400, abs=1) + + +def test_a_situation_claimants_supplied_loss_meets_the_allowance_in_the_election(): + # A £1,000 loss supplied for 2027 itself enters the election. At £1,000 + # the allowance saves nothing over the claimant's expenses, so they + # claim the loss; at £3,000 it saves £2,000, more than the loss, so they + # take the allowance and give the loss up. + person = { + "age": {2025: 40}, + "employment_income": {2025: 30_000}, + "self_employment_income": {2025: 5_000}, + "trading_loss": {2027: 1_000}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + kept = {ALLOWANCE: {"2027": 1_000}} + law = Simulation(situation=situation(person)) + at_1000 = Simulation(situation=situation(person), reform=kept) + assert law.calculate("uses_trading_allowance", 2027).tolist() == [True] + assert at_1000.calculate("uses_trading_allowance", 2027).tolist() == [False] + assert at_1000.calculate("trade_loss_relief_against_general_income", 2027)[ + 0 + ] == pytest.approx(1_000) + assert ( + law.calculate("income_tax", 2027)[0] < at_1000.calculate("income_tax", 2027)[0] + ) + + +def claimant_with_2025_inputs(): + return situation( + { + "age": {2025: 40}, + "employment_income": {2025: 30_000}, + "self_employment_income": {2025: 5_000}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + ) + + +def test_a_derivative_of_the_profit_leaves_the_data_year(): + # calculate_derivative steps the 2027 profit and sets it back, but the + # situation's profit was given for 2025, and stays read as measured then. + from policyengine_uk.dynamics.progression import calculate_derivative + + simulation = Simulation(situation=claimant_with_2025_inputs()) + before = simulation.calculate("income_tax", 2027)[0] + assert deduction(simulation, 2027) == pytest.approx([2_000]) + calculate_derivative( + simulation, + input_variable="self_employment_income", + year=2027, + count_adults=1, + ) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + assert simulation.calculate("income_tax", 2027)[0] == pytest.approx(before) + + +def test_a_modifier_that_writes_the_profit_back_changes_nothing(): + from policyengine_uk.utils.scenario import Scenario + + def write_back(simulation): + simulation.set_input( + "self_employment_income", + 2027, + simulation.calculate("self_employment_income", 2027), + ) + + reformed = Simulation( + situation=claimant_with_2025_inputs(), + scenario=Scenario(simulation_modifier=write_back), + ) + assert reformed.calculate("income_tax", 2027)[0] == pytest.approx( + reformed.baseline.calculate("income_tax", 2027)[0] + ) + assert deduction(reformed, 2027) == pytest.approx([2_000]) + + +def test_a_core_simulation_over_data_reads_the_first_year_of_the_profit(): + # Data stored for every year, as a policyengine-core simulation over a + # data frame holds them, were measured in their first year: 2025, under + # £1,000, so the 2027 raise gives the claimant £2,000. + from policyengine_core.simulations import Simulation as CoreSimulation + + from policyengine_uk.system import system + + record = pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "benunit_id": [1], + "household_id": [1], + "age": [40], + "self_employment_income": [5_000.0], + "trading_allowance_claim_status": ["CLAIMANT"], + "household_weight": [1.0], + } + ) + frame = pd.concat( + [ + record.rename(columns=lambda column: f"{column}__{year}") + for year in range(2025, 2029) + ], + axis=1, + ) + simulation = CoreSimulation(tax_benefit_system=system, dataset=frame) + assert simulation.calculate("trading_allowance_deduction", 2027).tolist() == ( + pytest.approx([2_000]) + ) diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py index a5358ebbf3..b472af146f 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py @@ -3,8 +3,8 @@ Survey data hold profit, not receipts. The claim status says which receipts lie behind a positive profit. A claimant's profit already deducts the -allowance legislated for the year the data were measured (here, a situation -whose status is supplied for the year simulated: £1,000 in 2026-27), so +allowance legislated for the year the profit was measured (here, a situation +whose profit is supplied for the year simulated: £1,000 in 2026-27), so their receipts are that profit plus £1,000. A non-claimant did not elect for partial relief. Unknown, the default, keeps the rules for supplied or missing receipts. @@ -18,10 +18,12 @@ allowance (gov.simulation.trading_allowance_claimant_expense_share), or their capital allowances if larger, never above the input allowance. Everyone who can take the allowance takes it unless a supplied current loss is worth more -than its saving; a claimant's saving is over deducting their expenses. +than its saving; a claimant's saving is over deducting their expenses, and +they take the allowance only where it covers those expenses. Invariants, each checked over many generated people in one reusable -simulation per allowance level and expense share: +simulation per allowance level and expense share, all on one run of each +simulation per draw: 1. Every status matches the reference: taxable trade profit, the Chapter 8 deduction, Class 4 profits and whether the person takes the allowance. @@ -66,7 +68,7 @@ amount, # actual expenses st.sampled_from(["unknown", "consistent", "below_profit"]), # receipts st.sampled_from([0.0, 0.0, 150.0, 2_500.0]), # capital allowances - st.sampled_from([0.0, 0.0, 0.0, 300.0, 2_000.0]), # losses brought forward + st.sampled_from([0.0, 0.0, 0.0, 300.0, 2_000.0, 25_000.0]), # losses b/f st.one_of(amount, st.floats(min_value=-2_000, max_value=0)), # misc income st.sampled_from([0.0, 0.0, 0.5, 1.0, 1.5]), # Chapter 8 share of it st.sampled_from([0.0, 0.0, 0.0, 0.0, 300.0, 3_000.0]), # current loss @@ -180,7 +182,9 @@ def reference(r, allowance, share): # capital allowances if larger, but never above the allowance they chose # instead. share_expenses = round(min(max(share, 0), 1) * INPUT_ALLOWANCE, 2) - claimant_expenses = np.minimum(np.maximum(share_expenses, capital), INPUT_ALLOWANCE) + claimant_expenses = np.minimum( + np.maximum(share_expenses, np.round(capital, 2)), INPUT_ALLOWANCE + ) ordinary = np.where(claimant, receipts - claimant_expenses, profit - capital) # Full relief (no election) or partial relief, trades first (s. 783AK). full = receipts + chapter_8 <= allowance @@ -198,15 +202,17 @@ def reference(r, allowance, share): chapter_8_relief = np.where(available, chapter_8_relief, 0) # Ordinary treatment wins where a supplied current loss is worth more # than the allowance's saving (ss. 783AL, 783AM). A claimant's saving is - # over deducting their expenses, negative where those do better, so they - # then take ordinary treatment with no loss at all. + # over deducting their expenses, and they take the allowance only where + # it does at least as well for the trade as those expenses: with losses + # brought forward covering both, the saving ties at nil either way. saving = ( np.maximum(ordinary - brought_forward, 0) - np.maximum(trade - brought_forward, 0) + chapter_8_relief ) claims_loss = loss > saving - claimant_takes = claimant & (allowance > 0) & ~claims_loss + covers = allowance >= claimant_expenses + claimant_takes = claimant & (allowance > 0) & covers & ~claims_loss ordinary_treatment = np.where(claimant, ~claimant_takes, claims_loss) trade = np.where(ordinary_treatment, ordinary, trade) chapter_8_relief = np.where(ordinary_treatment, 0, chapter_8_relief) @@ -226,7 +232,7 @@ def reference(r, allowance, share): near_full = non_claimant & finite near_full &= np.abs(np.where(finite, receipts, 0) + chapter_8 - allowance) <= margin exact_tie = claimant & (loss == 0) & (allowance == claimant_expenses) - near_loss = (loss > 0) | claimant + near_loss = (loss > 0) | (claimant & covers) near_loss &= (np.abs(loss - saving) <= margin) & ~exact_tie advantage = np.where(np.isfinite(with_allowance), ordinary - with_allowance, -1) return dict( @@ -255,9 +261,15 @@ def tolerance(*values): @SETTINGS @given(cases) -def test_every_status_matches_the_act(sims, drawn): +def test_every_status_matches_the_act_and_more_allowance_never_charges_more( + sims, drawn +): + # One run of each simulation per draw serves every invariant, which keeps + # the test's CI time down. + results = {} for (allowance, share), sim in sims.items(): - r = run(sim, drawn) + r = results[(allowance, share)] = run(sim, drawn) + # 1. Every status matches the reference. expected = reference(r, allowance, share) margin = tolerance(r["receipts"], r["profit"], r["chapter_8"], allowance) compared = ~expected["fragile"] @@ -269,17 +281,11 @@ def test_every_status_matches_the_act(sims, drawn): ) compared = ~expected["fragile_uses"] assert (r["uses"] == expected["uses"])[compared].all(), (allowance, share) - - -@SETTINGS -@given(cases) -def test_a_claimant_gets_only_the_allowances_excess_over_the_input_allowance( - sims, drawn -): - for (allowance, share), sim in sims.items(): - r = run(sim, drawn) + # 2. A claimant's deductions never exceed the allowance's excess over + # £1,000; the deduction gives back the capital allowances deducted + # separately. With the default share a cut leaves their profit as + # reported. claimant = (r["status"] == "CLAIMANT") & (r["profit"] > 0) - # The deduction gives back the capital allowances deducted separately. relief = r["trade_relief"] + r["capital"] + r["chapter_8_relief"] excess = max(allowance - INPUT_ALLOWANCE, 0) assert (relief[claimant] <= excess + tolerance(allowance)).all(), allowance @@ -289,25 +295,15 @@ def test_a_claimant_gets_only_the_allowances_excess_over_the_input_allowance( np.abs(r["taxable"] - reported)[claimant] <= tolerance(r["profit"])[claimant] ).all(), allowance - - -@SETTINGS -@given(cases) -def test_a_larger_allowance_never_raises_taxable_profit(sims, drawn): + # 3. A larger allowance never raises taxable trade profit. for share in (1.0, 0.5, 0.0): levels = sorted(a for a, s in SCENARIOS if s == share) - taxable = [run(sims[(a, share)], drawn)["taxable"] for a in levels] + taxable = [results[(a, share)]["taxable"] for a in levels] for smaller, larger in zip(taxable, taxable[1:]): assert (larger <= smaller + tolerance(smaller)).all(), share - - -@SETTINGS -@given(cases) -def test_a_smaller_expense_share_never_lowers_a_claimants_taxable_profit(sims, drawn): + # 4. A smaller expense share never lowers a claimant's taxable profit. for allowance in (0, 300, 500): - taxable = [ - run(sims[(allowance, share)], drawn)["taxable"] for share in (1.0, 0.5, 0.0) - ] + taxable = [results[(allowance, share)]["taxable"] for share in (1.0, 0.5, 0.0)] for larger_share, smaller_share in zip(taxable, taxable[1:]): assert (smaller_share >= larger_share - tolerance(larger_share)).all() @@ -325,7 +321,7 @@ def test_a_claimant_with_no_loss_takes_an_allowance_equal_to_their_expenses( the tie: the input allowance is £1,000 in 2026-27 and £3,000 in 2027-28, and 0.55 x £3,000 is a hair above £1,650 in floating point.""" rng = np.random.default_rng(2764) - count = 2_000 + count = 500 expenses = round(share * {2026: 1_000, 2027: 3_000}[year], 2) people = {f"person_{i}": {"age": {year: 40}} for i in range(count)} simulation = Simulation( diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index 715569e265..9d689053e1 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -28,12 +28,16 @@ def _data_year(person, period): - Other data without years, such as a policyengine-core Dataset: the earliest year self_employment_income was supplied for, for the same reason. - - A situation: the latest year, up to the one simulated, for which - self_employment_income was supplied. A profit carried from an earlier - year keeps that year's allowance: a 2025-26 profit simulated in - 2027-28 deducted £1,000, and gains from the raise to £3,000. The - builder stores a year's inputs for everyone in the situation, giving - anyone not set the default, so one year serves them all. + - A situation: the latest year, up to the one simulated, for which the + situation gave self_employment_income, as recorded when it was built + (Simulation.build_from_situation), so that a later write of the + profit, by a scenario's modifier or a derivative, does not move it. A + profit carried from an earlier year keeps that year's allowance: a + 2025-26 profit simulated in 2027-28 deducted £1,000, and gains from + the raise to £3,000. The builder stores a year's inputs for everyone + in the situation, giving anyone not set the default, so one year + serves them all. A simulation built some other way reads the profit's + supplied years as they stand. With no profit supplied, the year simulated. Not handled: data cannot declare the year their incomes were measured @@ -43,20 +47,24 @@ def _data_year(person, period): from policyengine_uk.data import UKMultiYearDataset simulation = person.simulation - supplied = supplied_input_periods(person, "self_employment_income") if built_from_data(simulation): # Checked by type: a policyengine-core Dataset loads any attribute # it is asked for as a table, so it cannot be asked for years. dataset = getattr(simulation, "dataset", None) if isinstance(dataset, UKMultiYearDataset): return min(dataset.years) + supplied = supplied_input_periods(person, "self_employment_income") return (supplied[0] if supplied else period).start.year - earlier = [ - supplied_period - for supplied_period in supplied - if supplied_period.start <= period.start - ] - return (earlier[-1] if earlier else period).start.year + years = getattr(simulation, "_situation_profit_years", None) + if years is None: + years = [ + supplied_period.start.year + for supplied_period in supplied_input_periods( + person, "self_employment_income" + ) + ] + earlier = [year for year in years if year <= period.start.year] + return earlier[-1] if earlier else period.start.year def trading_allowance_reliefs(person, period, parameters): @@ -123,8 +131,11 @@ def trading_allowance_reliefs(person, period, parameters): are the allowance, which leaves receipts less the allowance (nil within it), and ordinary treatment, which leaves receipts less those expenses and lets a supplied current loss be claimed. They make the - same election as everyone else: the allowance, unless its saving over - their expenses is negative or smaller than such a loss. So under the + same election as everyone else: the allowance, if it covers their + expenses and its saving over them is at least such a loss. (Covering + them matters where losses brought forward absorb both options: the + saving then ties at nil, and the allowance below their expenses would + charge Class 4, which does not see those losses, more.) So under the default share a claimant with no loss keeps the allowance at or above the input allowance, where the deduction is its excess over the input allowance, capped at profit, and a cut leaves their profit as @@ -172,7 +183,7 @@ def trading_allowance_reliefs(person, period, parameters): claimant_expenses = min_( max_( round_(min_(max_(expense_share, 0), 1) * input_allowance, 2), - claimant_capital_allowances, + round_(claimant_capital_allowances, 2), ), input_allowance, ) @@ -261,8 +272,14 @@ def trading_allowance_reliefs(person, period, parameters): # A claimant makes the same election against their own options: the # allowance unless it does worse than their expenses, or than a larger # supplied current loss. Ties take the allowance. + # The allowance must also cover their expenses: where losses brought + # forward absorb both options the saving ties at nil, and the allowance + # would then charge Class 4, which does not see those losses, more. claimant_takes_allowance = ( - claimant & (allowance > 0) & ~(current_loss > claimant_saving) + claimant + & (allowance > 0) + & (allowance >= claimant_expenses) + & ~(current_loss > claimant_saving) ) return ( where( diff --git a/policyengine_uk/variables/input/trading_allowance_claim_status.py b/policyengine_uk/variables/input/trading_allowance_claim_status.py index a824c3f579..7cf4abce2e 100644 --- a/policyengine_uk/variables/input/trading_allowance_claim_status.py +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -37,10 +37,10 @@ class trading_allowance_claim_status(Variable): "allowances, below that allowance but unknown, are " "gov.simulation.trading_allowance_claimant_expense_share of it, or " "the capital allowances supplied if larger. Like everyone else they " - "keep the allowance, and give up current trading losses, unless " - "deducting those expenses (ordinary treatment, s. 783AM), with any " - "supplied current loss claimed, is worth more. Flag only profits net " - "of the allowance: a " + "keep the allowance, and give up current trading losses, where it " + "covers those expenses, unless deducting them (ordinary treatment, s. " + "783AM), with any supplied current loss claimed, is worth more. Flag " + "only profits net of the allowance: a " "person whose receipts were within it was fully relieved without a " "claim, and their profit, if reported, is not net of it; leave them " "UNKNOWN. NON_CLAIMANT: the profit is receipts less actual expenses, " From b2bbf882243b1d63b8f1b4f3c112366a5d22c6fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:27:16 +0000 Subject: [PATCH 7/9] Address fifth review: record paths, texts, covered ties A situation that gave no profit at its build now reads the years a profit is set for, and build_from_populations recomputes the recorded profit years from the populations it takes. A profit set after a build that gave one still leaves the recorded year, which the docs now say. Texts: the reliefs docstring no longer claims the tax never jumps (Class 4 can, where losses brought forward absorb the saving, as for every status); the deduction's documentation says claimants keep the allowance only where it covers their expenses, ties included; the input's documentation and the changelog describe the recorded year. Tests: a YAML case pins covered claimants whose losses brought forward absorb both options to the allowance; the property test checks Class 4 profits never rise with the allowance and compares those claimants; the data-year file covers the three record paths. The property fixture and the tie sweeps change a built simulation's parameters rather than pass a reform, which skips a baseline build they never read. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2764.added.md | 2 +- policyengine_uk/simulation.py | 19 +++-- .../trading_allowance_claim_status.yaml | 19 +++++ ...rading_allowance_claim_status_data_year.py | 39 ++++++++++ ...ading_allowance_claim_status_properties.py | 77 +++++++++++-------- policyengine_uk/utils/trading_allowance.py | 26 ++++--- .../allowances/trading_allowance_deduction.py | 8 +- .../input/trading_allowance_claim_status.py | 9 ++- 8 files changed, 144 insertions(+), 55 deletions(-) diff --git a/changelog.d/2764.added.md b/changelog.d/2764.added.md index 7536a8e823..3e2da3449d 100644 --- a/changelog.d/2764.added.md +++ b/changelog.d/2764.added.md @@ -1 +1 @@ -Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the profit was measured (a dataset's first year, or in a situation the latest year, up to the one simulated, for which the profit was supplied), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1), or their capital allowances if larger, never more than that allowance: like everyone else, they keep the allowance where it covers those expenses, unless deducting them, or a supplied current loss, is worth more, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. +Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the profit was measured (a dataset's first year, or in a situation the latest year, up to the one simulated, that the situation gave the profit for when it was built), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1), or their capital allowances if larger, never more than that allowance: like everyone else, they keep the allowance where it covers those expenses, unless deducting them, or a supplied current loss, is worth more, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 946739acc1..94432a3a82 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -630,6 +630,9 @@ def build_from_populations(self, populations) -> None: # the values the new populations hold, and only those: an equal value # a rebuild stores later (from a situation or a frame) is supplied. keep_population_records(self, adopted) + # The new populations bring their own self-employment profits, so + # record the years those were supplied for (see build_from_situation). + self._situation_profit_years = self._supplied_profit_years() def set_input(self, variable_name: str, period: Period, value) -> None: # What is set here is supplied, even a value equal to the model's own, @@ -1069,6 +1072,15 @@ def apply_parameter_assignments(self, assignments: list) -> None: # recompute them under the rebuilt parameters. self._invalidate_all_caches() + def _supplied_profit_years(self) -> tuple: + """The years self_employment_income is supplied for, earliest first.""" + return tuple( + period.start.year + for period in supplied_input_periods( + self.populations["person"], "self_employment_income" + ) + ) + def build_from_situation(self, situation: Dict) -> None: """Build simulation from a situation dictionary. @@ -1091,12 +1103,7 @@ def build_from_situation(self, situation: Dict) -> None: # was measured from them (utils/trading_allowance.py, _data_year), so # a later write of the profit, by a scenario or a derivative, must # not move it. - self._situation_profit_years = tuple( - period.start.year - for period in supplied_input_periods( - self.populations["person"], "self_employment_income" - ) - ) + self._situation_profit_years = self._supplied_profit_years() self.dataset = UKSingleYearDataset.from_simulation( self, fiscal_year=self.default_input_period diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml index 919d6f033e..3972deeeea 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml @@ -621,6 +621,25 @@ # (20,000 - 12,570) x 6% ni_class_4: 445.80 +- name: Losses brought forward that absorb both options leave a covered claimant on the allowance. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 + self_employment_income: 20_000 + loss_relief: 21_000 + trading_allowance_claim_status: CLAIMANT + output: + # The £3,000 allowance covers their £1,000 of expenses. The losses absorb + # either option, so income tax cannot tell them apart, and the tie takes + # the allowance: receipts 21,000 less 3,000. Class 4 does not see these + # losses and charges that. + trading_allowance_deduction: 2_000 + uses_trading_allowance: true + ni_class_4_profits_before_losses: 18_000 + # (18,000 - 12,570) x 6% + ni_class_4: 325.80 + - name: An allowance equal to a claimant's capital allowances keeps the allowance. period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py index 3693518e12..990008558e 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py @@ -274,6 +274,45 @@ def write_back(simulation): assert deduction(reformed, 2027) == pytest.approx([2_000]) +def test_a_situation_built_without_a_profit_reads_the_years_one_is_set_for(): + # Nothing was recorded at the build, so the profit set afterwards for + # 2025 is the one measured: under £1,000, so the 2027 raise gives £2,000. + import numpy as np + + simulation = Simulation(situation=situation({"age": {2025: 40}})) + simulation.set_input("self_employment_income", 2025, np.array([5_000.0])) + simulation.set_input("trading_allowance_claim_status", 2025, np.array(["CLAIMANT"])) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + + +def test_a_profit_set_after_the_build_leaves_the_recorded_year(): + # The situation gave a 2025 profit, so a 2027 profit set afterwards is + # read as measured in 2025, as a modifier's or a derivative's would be. + import numpy as np + + simulation = Simulation(situation=claimant_with_2025_inputs()) + simulation.set_input("self_employment_income", 2027, np.array([5_000.0])) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + + +def test_replacing_the_populations_takes_their_profits_years(): + # Populations taken from a situation whose profit was given for 2027 + # bring that year, so the profit already deducts £3,000, as in a fresh + # build from that situation. + donor = Simulation( + situation=situation( + { + "age": {2027: 40}, + "self_employment_income": {2027: 5_000}, + "trading_allowance_claim_status": {2027: "CLAIMANT"}, + } + ) + ) + simulation = Simulation(situation=claimant_with_2025_inputs()) + simulation.build_from_populations(donor.populations) + assert deduction(simulation, 2027) == [0] + + def test_a_core_simulation_over_data_reads_the_first_year_of_the_profit(): # Data stored for every year, as a policyengine-core simulation over a # data frame holds them, were measured in their first year: 2025, under diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py index b472af146f..d48ed801d3 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py @@ -29,7 +29,8 @@ deduction, Class 4 profits and whether the person takes the allowance. 2. A claimant's deductions never exceed the allowance's excess over £1,000, and with the default share a cut leaves their profit as reported. -3. A larger allowance never raises taxable trade profit, for any status. +3. A larger allowance never raises taxable trade profit, nor Class 4 + profits, for any status. 4. A smaller expense share never lowers a claimant's taxable trade profit. """ @@ -89,21 +90,27 @@ def sims(): }, } return { - (allowance, share): Simulation( - situation=situation, - reform={ - "gov.hmrc.income_tax.allowances.trading_allowance": { - str(YEAR): allowance - }, - "gov.simulation.trading_allowance_claimant_expense_share": { - str(YEAR): share - }, - }, - ) + (allowance, share): changed(Simulation(situation=situation), allowance, share) for allowance, share in SCENARIOS } +def changed(simulation, allowance, share, year=YEAR): + """The simulation with the allowance and expense share changed for the + year. Changing the parameters of a built simulation skips the baseline a + reform would build, which halves the fixture's cost; the input allowance + still comes from the unreformed tree.""" + simulation.apply_parameter_changes( + { + "gov.hmrc.income_tax.allowances.trading_allowance": {str(year): allowance}, + "gov.simulation.trading_allowance_claimant_expense_share": { + str(year): share + }, + } + ) + return simulation + + def run(sim, drawn): ( status, @@ -231,7 +238,12 @@ def reference(r, allowance, share): finite = np.isfinite(receipts) near_full = non_claimant & finite near_full &= np.abs(np.where(finite, receipts, 0) + chapter_8 - allowance) <= margin - exact_tie = claimant & (loss == 0) & (allowance == claimant_expenses) + # Exact ties: the allowance equal to the expenses, or losses brought + # forward clearly absorbing both options, where both savings are nil. + absorbed = (ordinary <= brought_forward - margin) & ( + with_allowance <= brought_forward - margin + ) + exact_tie = claimant & (loss == 0) & ((allowance == claimant_expenses) | absorbed) near_loss = (loss > 0) | (claimant & covers) near_loss &= (np.abs(loss - saving) <= margin) & ~exact_tie advantage = np.where(np.isfinite(with_allowance), ordinary - with_allowance, -1) @@ -295,12 +307,14 @@ def test_every_status_matches_the_act_and_more_allowance_never_charges_more( np.abs(r["taxable"] - reported)[claimant] <= tolerance(r["profit"])[claimant] ).all(), allowance - # 3. A larger allowance never raises taxable trade profit. + # 3. A larger allowance never raises taxable trade profit, nor the + # profits Class 4 charges before its own losses. for share in (1.0, 0.5, 0.0): levels = sorted(a for a, s in SCENARIOS if s == share) - taxable = [results[(a, share)]["taxable"] for a in levels] - for smaller, larger in zip(taxable, taxable[1:]): - assert (larger <= smaller + tolerance(smaller)).all(), share + for name in ("taxable", "class_4_profits"): + figures = [results[(a, share)][name] for a in levels] + for smaller, larger in zip(figures, figures[1:]): + assert (larger <= smaller + tolerance(smaller)).all(), (name, share) # 4. A smaller expense share never lowers a claimant's taxable profit. for allowance in (0, 300, 500): taxable = [results[(allowance, share)]["taxable"] for share in (1.0, 0.5, 0.0)] @@ -324,20 +338,21 @@ def test_a_claimant_with_no_loss_takes_an_allowance_equal_to_their_expenses( count = 500 expenses = round(share * {2026: 1_000, 2027: 3_000}[year], 2) people = {f"person_{i}": {"age": {year: 40}} for i in range(count)} - simulation = Simulation( - situation={ - "people": people, - "benunits": {f"b_{i}": {"members": [f"person_{i}"]} for i in range(count)}, - "households": { - f"h_{i}": {"members": [f"person_{i}"]} for i in range(count) - }, - }, - reform={ - "gov.hmrc.income_tax.allowances.trading_allowance": {str(year): allowance}, - "gov.simulation.trading_allowance_claimant_expense_share": { - str(year): share - }, - }, + simulation = changed( + Simulation( + situation={ + "people": people, + "benunits": { + f"b_{i}": {"members": [f"person_{i}"]} for i in range(count) + }, + "households": { + f"h_{i}": {"members": [f"person_{i}"]} for i in range(count) + }, + } + ), + allowance, + share, + year, ) some = rng.random(count) < 0.5 simulation.set_input( diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index 9d689053e1..debf9d21db 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -30,14 +30,15 @@ def _data_year(person, period): reason. - A situation: the latest year, up to the one simulated, for which the situation gave self_employment_income, as recorded when it was built - (Simulation.build_from_situation), so that a later write of the - profit, by a scenario's modifier or a derivative, does not move it. A - profit carried from an earlier year keeps that year's allowance: a - 2025-26 profit simulated in 2027-28 deducted £1,000, and gains from - the raise to £3,000. The builder stores a year's inputs for everyone - in the situation, giving anyone not set the default, so one year - serves them all. A simulation built some other way reads the profit's - supplied years as they stand. + or its populations were replaced (Simulation.build_from_situation and + build_from_populations). A profit set after that does not move it, so + neither does a scenario's modifier or a derivative that writes the + profit. A profit carried from an earlier year keeps that year's + allowance: a 2025-26 profit simulated in 2027-28 deducted £1,000, and + gains from the raise to £3,000. The builder stores a year's inputs for + everyone in the situation, giving anyone not set the default, so one + year serves them all. A situation that gave no profit, or a simulation + built some other way, reads the profit's supplied years as they stand. With no profit supplied, the year simulated. Not handled: data cannot declare the year their incomes were measured @@ -56,7 +57,7 @@ def _data_year(person, period): supplied = supplied_input_periods(person, "self_employment_income") return (supplied[0] if supplied else period).start.year years = getattr(simulation, "_situation_profit_years", None) - if years is None: + if not years: years = [ supplied_period.start.year for supplied_period in supplied_input_periods( @@ -140,8 +141,11 @@ def trading_allowance_reliefs(person, period, parameters): the input allowance, where the deduction is its excess over the input allowance, capped at profit, and a cut leaves their profit as reported, the lower bound of its yield; a claimant with a loss claims - it until the allowance saves more. The tax they pay never jumps as - the allowance or the loss moves. The deduction is negative where it + it until the allowance saves more, where income tax is the same + either way. Class 4 can still jump with the loss where losses brought + forward absorb the income tax saving: any supplied current loss then + wins the election, the convention every status shares. The deduction + is negative where it gives back capital allowances or charges what a cut adds. Their receipts take up the allowance before income not otherwise charged, as other receipts do. 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 46c397ca5f..9e618f11ec 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 @@ -22,7 +22,8 @@ class trading_allowance_deduction(Variable): "(trading_allowance_claim_status) with a profit, the profit already " "deducts the allowance legislated for the year the input data were " "measured (a dataset's first year, or in a situation the latest " - "year, up to the one simulated, for which the profit was supplied), " + "year, up to the one simulated, for which the situation gave the " + "profit), " "so their receipts are their profit plus it, whatever " "receipts are supplied, and the deduction is the allowance's excess " "over it, capped at profit. It also gives back any capital allowances " @@ -34,8 +35,9 @@ class trading_allowance_deduction(Variable): "allowance: electing for partial relief after a raise is a change in " "behaviour. A claimant's ordinary treatment deducts the expenses " "that share sets, or their capital allowances if larger, and their " - "allowance saving is over those. For everyone, claimants included, " - "the model compares the raw loss " + "allowance saving is over those; they keep the allowance only where " + "it covers those expenses, ties included. For everyone else, and for " + "claimants where it does, 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, " "including its relief on miscellaneous income. " diff --git a/policyengine_uk/variables/input/trading_allowance_claim_status.py b/policyengine_uk/variables/input/trading_allowance_claim_status.py index 7cf4abce2e..e4571c44eb 100644 --- a/policyengine_uk/variables/input/trading_allowance_claim_status.py +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -22,9 +22,12 @@ class trading_allowance_claim_status(Variable): "the engine projects to later years; data cannot declare another, so " "a file saved from a later projected year is read as measured in its " "first year. In a situation it is the latest year, up to the one " - "simulated, for which self_employment_income was supplied, so a " - "profit carried from an earlier year keeps that year's allowance, " - "whatever year this status was given for. The model reads the " + "simulated, that the situation gave self_employment_income for, as " + "recorded when it was built, so a profit carried from an earlier " + "year keeps that year's allowance, whatever year this status was " + "given for, and a profit set after the build does not move it. A " + "situation built with no profit reads the years one was set for. " + "The model reads the " "allowance legislated for that year, which reforms do not move. The " "status describes a positive profit: a person with no " "profit has no trade receipts for it to describe, and a claimant is " From cbb8547e262dc4998bb9300393d03a7335923e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:36:23 +0000 Subject: [PATCH 8/9] Address sixth review and raise the test shards' time limit CI: the Python shards' time limit goes from 75 to 90 minutes, and the YAML job's, which its comment ties to theirs. On 1 November the shards took 63 to 75 minutes on runners of varying speed, and the limit cancelled shards with no test failing (runs 41303444320, 41313872613 and 41326415023). A situation that gave no profit at its build records the years of the first profit set afterwards, so a later write (a derivative's, a branch's) no longer moves its data year; _data_year falls back to the input history only where no recorded year is at or before the one simulated. build_from_populations takes the donor simulation's record, or a data-built donor's first year, instead of the donor's live input history. Texts: the reliefs docstring says which figures can step where the election switches; the changelog, the deduction's and the input's documentation and the data-year tests' docstring describe the recorded year; the fixture's saving reads about a third. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test-suite.yaml | 12 ++- changelog.d/2764.added.md | 2 +- changelog.d/2764.changed.md | 1 + policyengine_uk/simulation.py | 42 ++++++++- ...rading_allowance_claim_status_data_year.py | 93 ++++++++++++++++++- ...ading_allowance_claim_status_properties.py | 4 +- policyengine_uk/utils/trading_allowance.py | 49 ++++++---- .../allowances/trading_allowance_deduction.py | 6 +- .../input/trading_allowance_claim_status.py | 13 ++- 9 files changed, 181 insertions(+), 41 deletions(-) create mode 100644 changelog.d/2764.changed.md diff --git a/.github/workflows/test-suite.yaml b/.github/workflows/test-suite.yaml index 0ea9cd70c6..58143bf6a5 100644 --- a/.github/workflows/test-suite.yaml +++ b/.github/workflows/test-suite.yaml @@ -88,9 +88,9 @@ jobs: # after every test had passed, with the process still alive 11 and 18 # minutes after pytest's summary (runs 40895005971 and 40917254004). # The runner most likely runs short of memory, because every case's - # simulation is kept to the end (#2741). 75 minutes matches the Python + # simulation is kept to the end (#2741). 90 minutes matches the Python # shards, which Result waits for anyway, until the cases are released. - timeout-minutes: 75 + timeout-minutes: 90 steps: - name: Checkout repo uses: actions/checkout@v6 @@ -185,7 +185,13 @@ jobs: # 59m32s and the 60-minute limit then cancelled the job during its # Codecov upload, which took that pull request out of the queue. # 75 minutes leaves room for a slow runner and still stops a hung shard. - timeout-minutes: 75 + # It stopped leaving room on 1 November: #2765's shards took 63 to 75 + # minutes on runners of varying speed (runs 41303444320, 41313872613 and + # 41326415023), and the limit cancelled shards 2/6 and 3/6, then 3/6 at + # 99% with no test failing, while the same tests took 19% longer than a + # run earlier that day. 90 minutes leaves that room again and still + # stops a hung shard. + timeout-minutes: 90 strategy: fail-fast: false matrix: diff --git a/changelog.d/2764.added.md b/changelog.d/2764.added.md index 3e2da3449d..f57e44c1f8 100644 --- a/changelog.d/2764.added.md +++ b/changelog.d/2764.added.md @@ -1 +1 @@ -Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the profit was measured (a dataset's first year, or in a situation the latest year, up to the one simulated, that the situation gave the profit for when it was built), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1), or their capital allowances if larger, never more than that allowance: like everyone else, they keep the allowance where it covers those expenses, unless deducting them, or a supplied current loss, is worth more, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. +Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the profit was measured (a dataset's first year, or in a situation the latest year, up to the one simulated, of those it gave the profit for, as recorded when it was built, when it was first given a profit, or when it took another simulation's populations), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1), or their capital allowances if larger, never more than that allowance: like everyone else, they keep the allowance where it covers those expenses, unless deducting them, or a supplied current loss, is worth more, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. diff --git a/changelog.d/2764.changed.md b/changelog.d/2764.changed.md new file mode 100644 index 0000000000..a49a955642 --- /dev/null +++ b/changelog.d/2764.changed.md @@ -0,0 +1 @@ +Raise the time limit of the Python test shards from 75 to 90 minutes, and of the YAML test job, which matches them: on 1 November shards took up to 75 minutes on slower runners and were cancelled with no test failing. diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 94432a3a82..3cd4759b09 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -618,6 +618,9 @@ def delete_arrays(self, variable: str, period: Period = None) -> None: del records[recorded_period] def build_from_populations(self, populations) -> None: + person_key = self.tax_benefit_system.person_entity.key + donor = getattr(populations.get(person_key), "simulation", None) + profit_years = self._profit_years_of(donor) # Take a copy of what another simulation made, so that neither changes # the other's values or records. populations, adopted = copy_other_populations(self, populations) @@ -630,9 +633,12 @@ def build_from_populations(self, populations) -> None: # the values the new populations hold, and only those: an equal value # a rebuild stores later (from a situation or a frame) is supplied. keep_population_records(self, adopted) - # The new populations bring their own self-employment profits, so - # record the years those were supplied for (see build_from_situation). - self._situation_profit_years = self._supplied_profit_years() + # The new populations bring their own self-employment profits, and + # with them the years those were measured in (see + # build_from_situation). + self._situation_profit_years = ( + profit_years if profit_years is not None else self._supplied_profit_years() + ) def set_input(self, variable_name: str, period: Period, value) -> None: # What is set here is supplied, even a value equal to the model's own, @@ -671,6 +677,16 @@ def set_input(self, variable_name: str, period: Period, value) -> None: hold_identifiers_in(self, variable_name, period) if variable_name in CGT_DATASET_INPUTS: self._capture_cgt_supplied_inputs(variable_name, replace_period=period) + if ( + variable_name == "self_employment_income" + and not self.built_from_dataset + and not getattr(self, "_situation_profit_years", None) + ): + # A situation that gave no profit when it was built takes the + # years of the first profit set afterwards, which later writes (a + # modifier's, a derivative's) then leave alone, as they leave the + # years a situation gives (see build_from_situation). + self._situation_profit_years = self._supplied_profit_years() def clone(self, *args, **kwargs) -> "Simulation": clone = super().clone(*args, **kwargs) @@ -1081,6 +1097,26 @@ def _supplied_profit_years(self) -> tuple: ) ) + def _profit_years_of(self, donor) -> Optional[tuple]: + """The recorded profit years that populations taken from ``donor`` + bring: its own record, or for data its first year, since data hold + a projected profit for every year. None for populations no other + simulation made, or a simulation rebuilding from its own.""" + if donor is None: + return None + if donor is self: + return getattr(self, "_situation_profit_years", None) + if built_from_data(donor): + dataset = getattr(donor, "dataset", None) + if isinstance(dataset, UKMultiYearDataset): + return (min(dataset.years),) + supplied = supplied_input_periods( + donor.populations[donor.tax_benefit_system.person_entity.key], + "self_employment_income", + ) + return (supplied[0].start.year,) if supplied else () + return getattr(donor, "_situation_profit_years", None) + def build_from_situation(self, situation: Dict) -> None: """Build simulation from a situation dictionary. diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py index 990008558e..6a11fc25a8 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py @@ -3,11 +3,12 @@ A claimant's profit already deducts the allowance legislated for that year: a dataset's first year, which Simulation projects to later years, or in a -situation the latest year, up to the one simulated, for which the profit was -supplied. These tests build small datasets and situations with earlier -inputs, so the data year and the simulated year differ, as they do for -survey data projected past the April 2027 raise to £3,000 (Budget 2026, -paragraph 6.254). +situation the latest year, up to the one simulated, of those it gave the +profit for, as recorded when it was built, first given a profit or took +another simulation's populations. These tests build small datasets and +situations with earlier inputs, so the data year and the simulated year +differ, as they do for survey data projected past the April 2027 raise to +£3,000 (Budget 2026, paragraph 6.254). """ import pandas as pd @@ -313,6 +314,88 @@ def test_replacing_the_populations_takes_their_profits_years(): assert deduction(simulation, 2027) == [0] +def claimant_given_2025_inputs_after_a_bare_build(): + import numpy as np + + simulation = Simulation( + situation=situation({"age": {2025: 40}, "employment_income": {2025: 30_000}}) + ) + simulation.set_input("self_employment_income", 2025, np.array([5_000.0])) + simulation.set_input("trading_allowance_claim_status", 2025, np.array(["CLAIMANT"])) + return simulation + + +def test_the_first_profit_set_after_a_bare_build_fixes_the_data_year(): + # The bare build recorded no profit, so the 2025 profit set first is the + # one measured, and a derivative's later writes of the 2027 profit leave + # it, as they do for a situation that gave the profit. + from policyengine_uk.dynamics.progression import calculate_derivative + + bare = claimant_given_2025_inputs_after_a_bare_build() + given = Simulation( + situation=situation( + { + "age": {2025: 40}, + "employment_income": {2025: 30_000}, + "self_employment_income": {2025: 5_000}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + ) + ) + arguments = dict(input_variable="self_employment_income", year=2027, count_adults=1) + assert calculate_derivative(bare, **arguments).tolist() == ( + calculate_derivative(given, **arguments).tolist() + ) + assert deduction(bare, 2027) == pytest.approx([2_000]) + + +def test_a_branch_that_sets_the_profit_keeps_the_data_year(): + import numpy as np + + branch = claimant_given_2025_inputs_after_a_bare_build().get_branch("raise") + branch.set_input("self_employment_income", 2027, np.array([5_000.0])) + assert deduction(branch, 2027) == pytest.approx([2_000]) + + +def test_with_no_recorded_year_up_to_the_one_simulated_a_set_profit_counts(): + # The situation gave a profit for 2028 only, so 2027 reads the 2025 + # profit set afterwards, as it would had every input been set that way. + import numpy as np + + simulation = Simulation( + situation=situation( + { + "age": {2025: 40}, + "self_employment_income": {2028: 5_000}, + "trading_allowance_claim_status": {2028: "CLAIMANT"}, + } + ) + ) + simulation.set_input("self_employment_income", 2025, np.array([5_000.0])) + simulation.set_input("trading_allowance_claim_status", 2025, np.array(["CLAIMANT"])) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + + +def test_replacing_the_populations_keeps_the_donors_record(): + # The donor gave a 2025 profit; its 2027 profit set afterwards leaves its + # data year at 2025, and populations taken from it bring that record. + import numpy as np + + donor = Simulation(situation=claimant_with_2025_inputs()) + donor.set_input("self_employment_income", 2027, np.array([5_000.0])) + simulation = Simulation(situation=situation({"age": {2026: 40}})) + simulation.build_from_populations(donor.populations) + assert deduction(donor, 2027) == pytest.approx([2_000]) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + + +def test_a_situation_taking_a_datasets_populations_reads_its_first_year(): + data = Simulation(dataset=traders(2025)) + simulation = Simulation(situation=situation({"age": {2026: 40}})) + simulation.build_from_populations(data.populations) + assert deduction(simulation, 2027)[0] == pytest.approx(2_000) + + def test_a_core_simulation_over_data_reads_the_first_year_of_the_profit(): # Data stored for every year, as a policyengine-core simulation over a # data frame holds them, were measured in their first year: 2025, under diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py index d48ed801d3..3dd3f28cc7 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py @@ -98,8 +98,8 @@ def sims(): def changed(simulation, allowance, share, year=YEAR): """The simulation with the allowance and expense share changed for the year. Changing the parameters of a built simulation skips the baseline a - reform would build, which halves the fixture's cost; the input allowance - still comes from the unreformed tree.""" + reform would build, which cuts the fixture's cost by about a third; the + input allowance still comes from the unreformed tree.""" simulation.apply_parameter_changes( { "gov.hmrc.income_tax.allowances.trading_allowance": {str(year): allowance}, diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index debf9d21db..33585b67a8 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -28,18 +28,23 @@ def _data_year(person, period): - Other data without years, such as a policyengine-core Dataset: the earliest year self_employment_income was supplied for, for the same reason. - - A situation: the latest year, up to the one simulated, for which the - situation gave self_employment_income, as recorded when it was built - or its populations were replaced (Simulation.build_from_situation and - build_from_populations). A profit set after that does not move it, so - neither does a scenario's modifier or a derivative that writes the - profit. A profit carried from an earlier year keeps that year's - allowance: a 2025-26 profit simulated in 2027-28 deducted £1,000, and - gains from the raise to £3,000. The builder stores a year's inputs for - everyone in the situation, giving anyone not set the default, so one - year serves them all. A situation that gave no profit, or a simulation - built some other way, reads the profit's supplied years as they stand. - With no profit supplied, the year simulated. + - A situation: the latest year, up to the one simulated, of those it + gave self_employment_income for, as recorded (Simulation): + - when it was built from a dictionary (build_from_situation); + - if it gave no profit then, when a profit is first set; + - when its populations are replaced (build_from_populations), with + the record of the simulation they come from, or a data-built one's + first year. + A profit set after the record does not move it, so neither does a + scenario's modifier or a derivative that writes the profit. A profit + carried from an earlier year keeps that year's allowance: a 2025-26 + profit simulated in 2027-28 deducted £1,000, and gains from the raise + to £3,000. The builder stores a year's inputs for everyone in the + situation, giving anyone not set the default, so one year serves them + all. + Where no recorded year is at or before the one simulated, as for a + simulation built some other way, the profit's supplied years as they + stand; with none, the year simulated. Not handled: data cannot declare the year their incomes were measured in, so a file saved from a later projected year, or a situation saved @@ -56,15 +61,16 @@ def _data_year(person, period): return min(dataset.years) supplied = supplied_input_periods(person, "self_employment_income") return (supplied[0] if supplied else period).start.year - years = getattr(simulation, "_situation_profit_years", None) - if not years: - years = [ + recorded = getattr(simulation, "_situation_profit_years", None) or () + earlier = [year for year in recorded if year <= period.start.year] + if not earlier: + earlier = [ supplied_period.start.year for supplied_period in supplied_input_periods( person, "self_employment_income" ) + if supplied_period.start.year <= period.start.year ] - earlier = [year for year in years if year <= period.start.year] return earlier[-1] if earlier else period.start.year @@ -141,10 +147,13 @@ def trading_allowance_reliefs(person, period, parameters): the input allowance, where the deduction is its excess over the input allowance, capped at profit, and a cut leaves their profit as reported, the lower bound of its yield; a claimant with a loss claims - it until the allowance saves more, where income tax is the same - either way. Class 4 can still jump with the loss where losses brought - forward absorb the income tax saving: any supplied current loss then - wins the election, the convention every status shares. The deduction + it until the allowance saves more. The election weighs the loss + against the saving in taxable trade profit, so other figures can step + where it switches, under the convention every status shares: Class 4, + where losses brought forward absorb that saving and any supplied + current loss wins; and income tax itself where a relief reads trade + profit, such as pension relief limited by relevant UK earnings or the + s. 24A cap on loss relief. The deduction is negative where it gives back capital allowances or charges what a cut adds. Their receipts take up the allowance before income not otherwise charged, 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 9e618f11ec..699d89b899 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 @@ -22,8 +22,10 @@ class trading_allowance_deduction(Variable): "(trading_allowance_claim_status) with a profit, the profit already " "deducts the allowance legislated for the year the input data were " "measured (a dataset's first year, or in a situation the latest " - "year, up to the one simulated, for which the situation gave the " - "profit), " + "year, up to the one simulated, of those the situation gave the " + "profit for when it was built, first given a profit or took its " + "populations from another simulation; failing one, the years a profit " + "is supplied for), " "so their receipts are their profit plus it, whatever " "receipts are supplied, and the deduction is the allowance's excess " "over it, capped at profit. It also gives back any capital allowances " diff --git a/policyengine_uk/variables/input/trading_allowance_claim_status.py b/policyengine_uk/variables/input/trading_allowance_claim_status.py index e4571c44eb..1e2459e3fb 100644 --- a/policyengine_uk/variables/input/trading_allowance_claim_status.py +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -22,11 +22,14 @@ class trading_allowance_claim_status(Variable): "the engine projects to later years; data cannot declare another, so " "a file saved from a later projected year is read as measured in its " "first year. In a situation it is the latest year, up to the one " - "simulated, that the situation gave self_employment_income for, as " - "recorded when it was built, so a profit carried from an earlier " - "year keeps that year's allowance, whatever year this status was " - "given for, and a profit set after the build does not move it. A " - "situation built with no profit reads the years one was set for. " + "simulated, of those the situation gave self_employment_income for, " + "as recorded when it was built, or, if it gave none then, when a " + "profit is first set; populations taken from another simulation " + "bring its record, or a dataset's first year. So a profit carried " + "from an earlier year keeps that year's allowance, whatever year " + "this status was given for, and a profit set after the record does " + "not move it. Where no recorded year is at or before the one " + "simulated, the years a profit is supplied for count. " "The model reads the " "allowance legislated for that year, which reforms do not move. The " "status describes a positive profit: a person with no " From 6f05924a3037917ab62d76b4a7d47e0cfbfaa880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:58:39 +0000 Subject: [PATCH 9/9] Address seventh review: freeze profit years at first read, gate pins - A situation's data year falls back to the profit years supplied when the allowance is first calculated after construction, not at the first write, so multi-year writes don't depend on their order and later writes don't move it. Branches read their root simulation's record. - uses_trading_allowance adds a claimant's election only where both deductions come from their formulas, not inputs or neutralised, and skips the reliefs where no one is a claimant with a profit. - Share the data-built rule (data_profit_year), narrow the property test's share invariant to claimants with no current loss, and add tests for write order, modifiers, the fallback, set_model_value, self-rebuilds, data donors and pinned or neutralised deductions. - Put the YAML job's time limit back at 75 minutes with a corrected comment; the Python shards stay at 90. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test-suite.yaml | 10 +- changelog.d/2764.added.md | 2 +- changelog.d/2764.changed.md | 2 +- policyengine_uk/simulation.py | 71 +++--- .../trading_allowance_claim_status.yaml | 88 +++++++ ...rading_allowance_claim_status_data_year.py | 224 ++++++++++++++++-- ...ading_allowance_claim_status_properties.py | 13 +- policyengine_uk/utils/supplied_inputs.py | 2 + policyengine_uk/utils/trading_allowance.py | 163 ++++++++----- .../allowances/trading_allowance_deduction.py | 6 +- .../allowances/uses_trading_allowance.py | 39 ++- .../class_4/ni_class_4_trading_loss.py | 8 +- .../input/trading_allowance_claim_status.py | 21 +- 13 files changed, 512 insertions(+), 137 deletions(-) diff --git a/.github/workflows/test-suite.yaml b/.github/workflows/test-suite.yaml index 58143bf6a5..55615ba17d 100644 --- a/.github/workflows/test-suite.yaml +++ b/.github/workflows/test-suite.yaml @@ -87,10 +87,12 @@ jobs: # was then not enough either: on 29 October jobs were cancelled at it # after every test had passed, with the process still alive 11 and 18 # minutes after pytest's summary (runs 40895005971 and 40917254004). - # The runner most likely runs short of memory, because every case's - # simulation is kept to the end (#2741). 90 minutes matches the Python - # shards, which Result waits for anyway, until the cases are released. - timeout-minutes: 90 + # The runner most likely ran short of memory, because every case's + # simulation was kept to the end (#2741). 75 minutes was set to match + # the Python shards until the cases were released. #2742 released them, + # and the job has since taken about 9 minutes (8.7 to 9.3 in six pull + # request runs on 1 November). + timeout-minutes: 75 steps: - name: Checkout repo uses: actions/checkout@v6 diff --git a/changelog.d/2764.added.md b/changelog.d/2764.added.md index f57e44c1f8..c4031c152c 100644 --- a/changelog.d/2764.added.md +++ b/changelog.d/2764.added.md @@ -1 +1 @@ -Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the profit was measured (a dataset's first year, or in a situation the latest year, up to the one simulated, of those it gave the profit for, as recorded when it was built, when it was first given a profit, or when it took another simulation's populations), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1), or their capital allowances if larger, never more than that allowance: like everyone else, they keep the allowance where it covers those expenses, unless deducting them, or a supplied current loss, is worth more, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. +Add `trading_allowance_claim_status`, a person input saying whether the input data's self-employment profit already deducts the trading allowance (claimant), deducts actual expenses above it (non-claimant), or is unknown (the default, which keeps the current treatment). A claimant's receipts are their profit plus the allowance legislated for the year the profit was measured (a dataset's first year, or in a situation the latest year, up to the one simulated, of those it gave the profit for, as recorded when it was built or took another simulation's populations, failing that as supplied when the allowance was first calculated), which reforms do not move, so a raise to any level relieves them by its excess over that allowance, up to their profit; their capital allowances, which partial relief replaces, are not deducted. Their expenses are a share of that allowance, set by `gov.simulation.trading_allowance_claimant_expense_share` (between 0 and 1, by default 1), or their capital allowances if larger, never more than that allowance: like everyone else, they keep the allowance where it covers those expenses, unless deducting them, or a supplied current loss, is worth more, so a cut adds by default nothing. A non-claimant gets the allowance only through full relief, where supplied receipts, or casual income alone with no profit, are within it. Where either trading allowance deduction is an input or neutralised, `uses_trading_allowance` follows the deductions applied, as for everyone. Income tax and Class 4 both read the result, so datasets without turnover can cost changes to the allowance, such as the Budget 2026 raise to £3,000, on the traders who already claim it. diff --git a/changelog.d/2764.changed.md b/changelog.d/2764.changed.md index a49a955642..e25c52465c 100644 --- a/changelog.d/2764.changed.md +++ b/changelog.d/2764.changed.md @@ -1 +1 @@ -Raise the time limit of the Python test shards from 75 to 90 minutes, and of the YAML test job, which matches them: on 1 November shards took up to 75 minutes on slower runners and were cancelled with no test failing. +Raise the time limit of the Python test shards from 75 to 90 minutes: on 1 November shards took up to 75 minutes on slower runners and were cancelled with no test failing. diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 3cd4759b09..9cce4898b6 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -95,7 +95,11 @@ forget_replaced_model_values, keep_population_records, model_value_input_records, - supplied_input_periods, +) +from policyengine_uk.utils.trading_allowance import ( + branch_root, + data_profit_year, + supplied_profit_years, ) from policyengine_uk.reforms import create_structural_reforms_from_parameters from policyengine_uk.parameters.gov.simulation.labour_supply_responses.aliases import ( @@ -312,6 +316,11 @@ def __init__( shared with the reform's baseline. Omit to retain GDP per capita. """ self._defer_uc_health_warning = True + # Construction calculates the trading allowance (the Universal Credit + # reform's set-up below) before a user can set the profits it reads, + # so the years it reads them for are kept only from the first + # calculation after (utils/trading_allowance.py, _data_year). + self._constructing = True self._cgt_input_provenance = {} self._cgt_defaulted_periods = set() self._cgt_calculation_depth = 0 @@ -469,6 +478,7 @@ def __init__( self.baseline._defer_uc_health_warning = False self.baseline._building_dated_reforms = None self.baseline._construction_reform_state = None + self.baseline._constructing = False self.calculated_periods = [] @@ -489,6 +499,7 @@ def __init__( self._building_dated_reforms = None self._construction_reform_state = None warn_shadowed_lcwra_reform(self) + self._constructing = False def _refresh_capital_gains_projection(self) -> None: projection = self.tax_benefit_system.capital_gains_projection @@ -636,8 +647,10 @@ def build_from_populations(self, populations) -> None: # The new populations bring their own self-employment profits, and # with them the years those were measured in (see # build_from_situation). - self._situation_profit_years = ( - profit_years if profit_years is not None else self._supplied_profit_years() + self._situation_profit_years, self._first_read_profit_years = ( + profit_years + if profit_years is not None + else (supplied_profit_years(self), None) ) def set_input(self, variable_name: str, period: Period, value) -> None: @@ -677,16 +690,6 @@ def set_input(self, variable_name: str, period: Period, value) -> None: hold_identifiers_in(self, variable_name, period) if variable_name in CGT_DATASET_INPUTS: self._capture_cgt_supplied_inputs(variable_name, replace_period=period) - if ( - variable_name == "self_employment_income" - and not self.built_from_dataset - and not getattr(self, "_situation_profit_years", None) - ): - # A situation that gave no profit when it was built takes the - # years of the first profit set afterwards, which later writes (a - # modifier's, a derivative's) then leave alone, as they leave the - # years a situation gives (see build_from_situation). - self._situation_profit_years = self._supplied_profit_years() def clone(self, *args, **kwargs) -> "Simulation": clone = super().clone(*args, **kwargs) @@ -707,6 +710,7 @@ def clone(self, *args, **kwargs) -> "Simulation": self, "_savings_income_projection", None ) clone._situation_profit_years = getattr(self, "_situation_profit_years", None) + clone._first_read_profit_years = getattr(self, "_first_read_profit_years", None) return clone def _isa_latest_input(self, name, period, *, earlier=False): @@ -1088,34 +1092,24 @@ def apply_parameter_assignments(self, assignments: list) -> None: # recompute them under the rebuilt parameters. self._invalidate_all_caches() - def _supplied_profit_years(self) -> tuple: - """The years self_employment_income is supplied for, earliest first.""" - return tuple( - period.start.year - for period in supplied_input_periods( - self.populations["person"], "self_employment_income" - ) - ) - def _profit_years_of(self, donor) -> Optional[tuple]: - """The recorded profit years that populations taken from ``donor`` - bring: its own record, or for data its first year, since data hold - a projected profit for every year. None for populations no other - simulation made, or a simulation rebuilding from its own.""" + """The record of profit years that populations taken from ``donor`` + bring (see utils/trading_allowance.py, _data_year): the years + recorded at its build and those taken when it was first calculated, + or for data, their first year, since data hold a projected profit + for every year. A branch's record is that of the simulation it was + made from, and a simulation rebuilding from its own populations + keeps its own. None for populations no simulation made.""" if donor is None: return None - if donor is self: - return getattr(self, "_situation_profit_years", None) + donor = branch_root(donor) if built_from_data(donor): - dataset = getattr(donor, "dataset", None) - if isinstance(dataset, UKMultiYearDataset): - return (min(dataset.years),) - supplied = supplied_input_periods( - donor.populations[donor.tax_benefit_system.person_entity.key], - "self_employment_income", - ) - return (supplied[0].start.year,) if supplied else () - return getattr(donor, "_situation_profit_years", None) + year = data_profit_year(donor) + return (() if year is None else (year,)), None + return ( + getattr(donor, "_situation_profit_years", None), + getattr(donor, "_first_read_profit_years", None), + ) def build_from_situation(self, situation: Dict) -> None: """Build simulation from a situation dictionary. @@ -1139,7 +1133,7 @@ def build_from_situation(self, situation: Dict) -> None: # was measured from them (utils/trading_allowance.py, _data_year), so # a later write of the profit, by a scenario or a derivative, must # not move it. - self._situation_profit_years = self._supplied_profit_years() + self._situation_profit_years = supplied_profit_years(self) self.dataset = UKSingleYearDataset.from_simulation( self, fiscal_year=self.default_input_period @@ -1531,6 +1525,7 @@ def _start_new_population(self, built_from_dataset: bool) -> None: self._isa_income_contracts = {} self._savings_income_projection = None self._situation_profit_years = None + self._first_read_profit_years = None def build_from_ids( self, diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml index 3972deeeea..11985d0a58 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml @@ -602,6 +602,26 @@ # (30,000 + 1,500 - 12,570) x 20% income_tax: 3_786 +- name: At today's allowance a smaller expense share raises the tax of a claimant with a loss. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.simulation.trading_allowance_claimant_expense_share: 0 + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 1_500 + trading_allowance_claim_status: CLAIMANT + output: + # Ordinary treatment deducts no expenses: 3,500 of profit. The allowance + # saves 1,000 over that, less than the loss, which is claimed. So the + # share matters without a cut: £200 more than at the default share (At + # the input allowance a claimant claims a current loss instead). + taxable_self_employment_income: 3_500 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 1_500 + # (30,000 + 3,500 - 1,500 - 12,570) x 20% + income_tax: 3_886 + - name: Losses brought forward do not keep a claimant on the allowance below their expenses. period: 2026 absolute_error_margin: 0.01 @@ -766,3 +786,71 @@ taxable_self_employment_income: 500 miscellaneous_income_trading_allowance_deduction: 0 taxable_miscellaneous_income: 900 + +- name: A claimant's pinned deduction leaves a raise to £3,000 their loss. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 + employment_income: 30_000 + self_employment_income: 2_500 + trading_loss: 1_500 + trading_allowance_claim_status: CLAIMANT + trading_allowance_deduction: 0 + output: + # The deduction applied is the input, not the claimant's election, so + # the flag follows it, as for anyone, and the loss is still claimed. + # Unpinned, the raise takes the loss for £2,000 of relief (A raise to + # £3,000 beats a claimant's smaller current loss). + uses_trading_allowance: false + trade_loss_relief_against_general_income: 1_500 + # (30,000 + 2,500 - 1,500 - 12,570) x 20%, as at £1,000 + income_tax: 3_686 + +- name: A claimant gives up a loss for the allowance's relief on casual income. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 + employment_income: 30_000 + self_employment_income: 500 + capital_allowances: 600 + miscellaneous_income: 1_000 + income_not_otherwise_charged: 1_000 + trading_loss: 800 + trading_allowance_claim_status: CLAIMANT + output: + # Receipts of 500 + 1,000 and casual income of 1,000 are within £3,000, + # so both are fully relieved. That saves 500 of trade profit over their + # £1,000 of expenses, and the 1,000 of casual income, more than the + # loss. The trade deduction gives back the capital allowances, so it is + # below nil: only the claimant's election marks them as taking it. + trading_allowance_deduction: -100 + miscellaneous_income_trading_allowance_deduction: 1_000 + uses_trading_allowance: true + trade_loss_relief_against_general_income: 0 + # (30,000 - 12,570) x 20% + income_tax: 3_486 + +- name: A claimant's pinned casual-income deduction leaves them their loss. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.allowances.trading_allowance: 3_000 + employment_income: 30_000 + self_employment_income: 500 + capital_allowances: 600 + miscellaneous_income: 1_000 + income_not_otherwise_charged: 1_000 + trading_loss: 800 + trading_allowance_claim_status: CLAIMANT + miscellaneous_income_trading_allowance_deduction: 0 + output: + # The casual-income relief the election counted on is not applied, so + # the flag follows the deductions applied, neither of them positive, and + # the loss is claimed rather than given up for nothing. + trading_allowance_deduction: -100 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 800 + # (30,000 + 1,000 - 800 - 12,570) x 20% + income_tax: 3_526 diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py index 6a11fc25a8..25208dcca1 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py @@ -4,11 +4,13 @@ A claimant's profit already deducts the allowance legislated for that year: a dataset's first year, which Simulation projects to later years, or in a situation the latest year, up to the one simulated, of those it gave the -profit for, as recorded when it was built, first given a profit or took -another simulation's populations. These tests build small datasets and -situations with earlier inputs, so the data year and the simulated year -differ, as they do for survey data projected past the April 2027 raise to -£3,000 (Budget 2026, paragraph 6.254). +profit for, as recorded when it was built or took another simulation's +populations, failing that as supplied when the allowance was first +calculated. These tests build small datasets and situations with earlier +inputs, so the data year and the simulated year differ, as they do for +survey data projected past the April 2027 raise to £3,000 (Budget 2026, +paragraph 6.254). The last checks that uses_trading_allowance follows a +deduction that does not come from its formula. """ import pandas as pd @@ -325,10 +327,11 @@ def claimant_given_2025_inputs_after_a_bare_build(): return simulation -def test_the_first_profit_set_after_a_bare_build_fixes_the_data_year(): - # The bare build recorded no profit, so the 2025 profit set first is the - # one measured, and a derivative's later writes of the 2027 profit leave - # it, as they do for a situation that gave the profit. +def test_a_profit_set_after_a_bare_build_is_kept_from_the_first_calculation(): + # The bare build recorded no profit, so the 2025 profit set before the + # first calculation is the one measured, and a derivative's later writes + # of the 2027 profit leave it, as they do for a situation that gave the + # profit. from policyengine_uk.dynamics.progression import calculate_derivative bare = claimant_given_2025_inputs_after_a_bare_build() @@ -396,10 +399,9 @@ def test_a_situation_taking_a_datasets_populations_reads_its_first_year(): assert deduction(simulation, 2027)[0] == pytest.approx(2_000) -def test_a_core_simulation_over_data_reads_the_first_year_of_the_profit(): - # Data stored for every year, as a policyengine-core simulation over a - # data frame holds them, were measured in their first year: 2025, under - # £1,000, so the 2027 raise gives the claimant £2,000. +def core_simulation_over_a_claimant_frame(): + """A policyengine-core simulation over a frame holding a claimant's £5,000 + profit for each year from 2025 to 2028.""" from policyengine_core.simulations import Simulation as CoreSimulation from policyengine_uk.system import system @@ -424,7 +426,201 @@ def test_a_core_simulation_over_data_reads_the_first_year_of_the_profit(): ], axis=1, ) - simulation = CoreSimulation(tax_benefit_system=system, dataset=frame) + return CoreSimulation(tax_benefit_system=system, dataset=frame) + + +def test_a_core_simulation_over_data_reads_the_first_year_of_the_profit(): + # Data stored for every year, as a policyengine-core simulation over a + # data frame holds them, were measured in their first year: 2025, under + # £1,000, so the 2027 raise gives the claimant £2,000. + simulation = core_simulation_over_a_claimant_frame() assert simulation.calculate("trading_allowance_deduction", 2027).tolist() == ( pytest.approx([2_000]) ) + + +def claimants(*profits): + """Claimants from 2025 with £30,000 of pay, one for each mapping of years + to self-employment profit given; an empty one gives no profit.""" + people = {} + for index, profit in enumerate(profits): + person = { + "age": {2025: 40}, + "employment_income": {2025: 30_000}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + if profit: + person["self_employment_income"] = profit + people[f"person_{index}"] = person + return { + "people": people, + "benunits": { + f"benunit_{index}": {"members": [name]} for index, name in enumerate(people) + }, + "households": {"household": {"members": list(people)}}, + } + + +def test_profits_set_one_year_at_a_time_read_as_a_dictionary_giving_them_all(): + # A £5,000 profit for each year from 2025 to 2030, set one year at a + # time and in any order before the first calculation, is read as the + # dictionary giving them all reads it: in 2027 the 2027 profit, measured + # under £3,000, which gains nothing from it. + import numpy as np + + years = range(2025, 2031) + given = Simulation(situation=claimants({year: 5_000 for year in years})) + assert deduction(given, 2027) == [0] + tax = given.calculate("income_tax", 2027)[0] + for order in (years, reversed(years), (2027, 2025, 2026, 2028, 2029, 2030)): + simulation = Simulation(situation=claimants({})) + for year in order: + simulation.set_input("self_employment_income", year, np.array([5_000.0])) + assert deduction(simulation, 2027) == [0] + assert simulation.calculate("income_tax", 2027)[0] == pytest.approx(tax) + + +def test_two_peoples_profits_set_in_turn_read_as_a_dictionary_giving_both(): + # One claimant's profit was given for 2025 and the other's for 2027; the + # builder stores each year for both, the other at nil. Set that way in + # either order, they are read as the dictionary reads them. + import numpy as np + + given = Simulation(situation=claimants({2025: 5_000}, {2027: 5_000})) + assert deduction(given, 2027) == [0, 0] + profits = {2025: [5_000.0, 0.0], 2027: [0.0, 5_000.0]} + for order in ((2025, 2027), (2027, 2025)): + simulation = Simulation(situation=claimants({}, {})) + for year in order: + simulation.set_input( + "self_employment_income", year, np.array(profits[year]) + ) + assert deduction(simulation, 2027) == [0, 0] + + +def test_a_modifier_writing_back_a_profit_on_a_bare_situation_changes_nothing(): + # The modifier writes back the 2025 profit, nil, before a 2027 profit is + # given to the reform and its baseline alike. Its year joins the 2027 + # one, so both read that profit as measured in 2027. + import numpy as np + + from policyengine_uk.utils.scenario import Scenario + + def write_back(simulation): + simulation.set_input( + "self_employment_income", + 2025, + simulation.calculate("self_employment_income", 2025), + ) + + reformed = Simulation( + situation=claimants({}), scenario=Scenario(simulation_modifier=write_back) + ) + for simulation in (reformed, reformed.baseline): + simulation.set_input("self_employment_income", 2027, np.array([5_000.0])) + assert deduction(reformed, 2027) == deduction(reformed.baseline, 2027) == [0] + assert reformed.calculate("income_tax", 2027)[0] == pytest.approx( + reformed.baseline.calculate("income_tax", 2027)[0] + ) + + +def test_writes_after_the_first_calculation_leave_a_later_profits_fallback(): + # The situation gave a profit for 2028 only, so 2027 reads the 2025 + # profit set before the first calculation. A derivative's writes of the + # 2027 profit, and a branch's, leave that year, as they leave it where + # every profit was set. + import numpy as np + + from policyengine_uk.dynamics.progression import calculate_derivative + + def given_2028_then_set_2025(): + simulation = Simulation(situation=claimants({2028: 5_000})) + simulation.set_input("self_employment_income", 2025, np.array([5_000.0])) + return simulation + + every_profit_set = Simulation(situation=claimants({})) + for year in (2025, 2028): + every_profit_set.set_input("self_employment_income", year, np.array([5_000.0])) + arguments = dict(input_variable="self_employment_income", year=2027, count_adults=1) + simulation = given_2028_then_set_2025() + assert calculate_derivative(simulation, **arguments).tolist() == ( + calculate_derivative(every_profit_set, **arguments).tolist() + ) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + parent = given_2028_then_set_2025() + branch = parent.get_branch("raise") + branch.set_input("self_employment_income", 2027, np.array([5_000.0])) + assert deduction(branch, 2027) == pytest.approx([2_000]) + assert deduction(parent, 2027) == pytest.approx([2_000]) + + +def test_a_profit_the_model_sets_is_not_a_year_supplied(): + # A scenario's own value (set_model_value) is not supplied, so a 2027 + # profit the model sets first leaves the 2025 profit set afterwards as + # the one measured. + import numpy as np + + from policyengine_uk.utils.supplied_inputs import set_model_value + + simulation = Simulation(situation=claimants({})) + set_model_value(simulation, "self_employment_income", 2027, np.array([5_000.0])) + simulation.set_input("self_employment_income", 2025, np.array([5_000.0])) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + + +def test_rebuilding_from_its_own_populations_keeps_the_record(): + # The 2027 profit set after the build stays out of the record when the + # simulation takes its own populations back. + import numpy as np + + simulation = Simulation(situation=claimant_with_2025_inputs()) + simulation.set_input("self_employment_income", 2027, np.array([5_000.0])) + simulation.build_from_populations(simulation.populations) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + + +def test_populations_from_data_without_years_bring_their_earliest_profit_year(): + # The frame holds a profit for each year from 2025, measured in 2025. + simulation = Simulation(situation=situation({"age": {2026: 40}})) + simulation.build_from_populations( + core_simulation_over_a_claimant_frame().populations + ) + assert deduction(simulation, 2027) == pytest.approx([2_000]) + + +def test_populations_pass_a_datasets_first_year_on(): + data = Simulation(dataset=traders(2025)) + first = Simulation(situation=situation({"age": {2026: 40}})) + first.build_from_populations(data.populations) + second = Simulation(situation=situation({"age": {2026: 40}})) + second.build_from_populations(first.populations) + assert deduction(second, 2027)[0] == pytest.approx(2_000) + + +def test_a_neutralised_deduction_keeps_a_raise_from_costing_a_claimant_their_loss(): + # The deduction applied is nil whatever the claimant chooses, so the + # flag follows it, as for anyone: they keep their £1,500 loss under a + # raise to £3,000, and pay what they pay at £1,000. + from policyengine_core.reforms import Reform + + class neutralise_deduction(Reform): + def apply(self): + self.neutralize_variable("trading_allowance_deduction") + + person = { + "age": {2026: 40}, + "employment_income": {2026: 30_000}, + "self_employment_income": {2026: 2_500}, + "trading_loss": {2026: 1_500}, + "trading_allowance_claim_status": {2026: "CLAIMANT"}, + } + simulation = Simulation( + situation=situation(person), + reform=(neutralise_deduction, {ALLOWANCE: {"2026": 3_000}}), + ) + assert simulation.calculate("uses_trading_allowance", 2026).tolist() == [False] + assert simulation.calculate( + "trade_loss_relief_against_general_income", 2026 + ).tolist() == pytest.approx([1_500]) + # (30,000 + 2,500 - 1,500 - 12,570) x 20% + assert simulation.calculate("income_tax", 2026).tolist() == pytest.approx([3_686]) diff --git a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py index 3dd3f28cc7..c27b85d04b 100644 --- a/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py @@ -31,7 +31,10 @@ and with the default share a cut leaves their profit as reported. 3. A larger allowance never raises taxable trade profit, nor Class 4 profits, for any status. -4. A smaller expense share never lowers a claimant's taxable trade profit. +4. A smaller expense share never lowers the taxable trade profit of a + claimant with no supplied current loss. With one, it can move them from + claiming the loss to the allowance, which lowers that profit but gives + the loss up; the YAML cases check their income tax. """ import numpy as np @@ -315,11 +318,15 @@ def test_every_status_matches_the_act_and_more_allowance_never_charges_more( figures = [results[(a, share)][name] for a in levels] for smaller, larger in zip(figures, figures[1:]): assert (larger <= smaller + tolerance(smaller)).all(), (name, share) - # 4. A smaller expense share never lowers a claimant's taxable profit. + # 4. A smaller expense share never lowers the taxable profit of a + # claimant with no supplied current loss. for allowance in (0, 300, 500): + no_loss = results[(allowance, 1.0)]["loss"] == 0 taxable = [results[(allowance, share)]["taxable"] for share in (1.0, 0.5, 0.0)] for larger_share, smaller_share in zip(taxable, taxable[1:]): - assert (smaller_share >= larger_share - tolerance(larger_share)).all() + assert (smaller_share >= larger_share - tolerance(larger_share))[ + no_loss + ].all() @pytest.mark.parametrize( diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index 7a6ac7434a..c04b516bcb 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -77,12 +77,14 @@ "pip_m_points", "local_bus_trips", "local_bus_uncapped_single_fare", + "miscellaneous_income_trading_allowance_deduction", "monthly_domestic_energy_consumption", "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "ni_epg_full_rate_applies", "tenure_type", "targeted_childcare_entitlement", + "trading_allowance_deduction", "trading_loss", "uc_carer_element", "uc_LCWRA_element", diff --git a/policyengine_uk/utils/trading_allowance.py b/policyengine_uk/utils/trading_allowance.py index 33585b67a8..783615ce86 100644 --- a/policyengine_uk/utils/trading_allowance.py +++ b/policyengine_uk/utils/trading_allowance.py @@ -1,5 +1,6 @@ """The trading allowance (ITTOIA 2005 Part 6A Chapter 1), shared by -trading_allowance_deduction and miscellaneous_income_trading_allowance_deduction. +trading_allowance_deduction, miscellaneous_income_trading_allowance_deduction +and uses_trading_allowance. One allowance covers an individual's "relevant income": the receipts of their relevant trades and their miscellaneous income, which s. 783AB defines @@ -18,8 +19,26 @@ ) -def _data_year(person, period): - """The year the input self-employment profit was measured. +def branch_root(simulation): + """The simulation a branch was made from, through every level of + branching; a simulation that is not a branch is its own.""" + while getattr(simulation, "parent_branch", None) is not None: + simulation = simulation.parent_branch + return simulation + + +def supplied_profit_years(simulation) -> tuple: + """The years self_employment_income is supplied for, earliest first.""" + person = simulation.populations[simulation.tax_benefit_system.person_entity.key] + return tuple( + period.start.year + for period in supplied_input_periods(person, "self_employment_income") + ) + + +def data_profit_year(simulation): + """The year a simulation built from data measured self-employment profit + in, or None if the data supply none. - A UK dataset: its first year, which the engine projects to later years, as the State Pension variables read it. Its inputs are stored @@ -28,50 +47,83 @@ def _data_year(person, period): - Other data without years, such as a policyengine-core Dataset: the earliest year self_employment_income was supplied for, for the same reason. - - A situation: the latest year, up to the one simulated, of those it - gave self_employment_income for, as recorded (Simulation): - - when it was built from a dictionary (build_from_situation); - - if it gave no profit then, when a profit is first set; - - when its populations are replaced (build_from_populations), with - the record of the simulation they come from, or a data-built one's - first year. - A profit set after the record does not move it, so neither does a - scenario's modifier or a derivative that writes the profit. A profit - carried from an earlier year keeps that year's allowance: a 2025-26 - profit simulated in 2027-28 deducted £1,000, and gains from the raise - to £3,000. The builder stores a year's inputs for everyone in the - situation, giving anyone not set the default, so one year serves them - all. - Where no recorded year is at or before the one simulated, as for a - simulation built some other way, the profit's supplied years as they - stand; with none, the year simulated. - - Not handled: data cannot declare the year their incomes were measured - in, so a file saved from a later projected year, or a situation saved - over several years, is read as measured in its first year. """ from policyengine_uk.data import UKMultiYearDataset - simulation = person.simulation + # Checked by type: a policyengine-core Dataset loads any attribute it is + # asked for as a table, so it cannot be asked for years. + dataset = getattr(simulation, "dataset", None) + if isinstance(dataset, UKMultiYearDataset): + return min(dataset.years) + supplied = supplied_profit_years(simulation) + return supplied[0] if supplied else None + + +def _profit_years_when_first_read(simulation) -> tuple: + """The years self_employment_income was supplied for when the trading + allowance was first calculated with a profit supplied after the + simulation was built, kept from then on. Calculations while + Simulation builds itself read the years as they stand.""" + years = getattr(simulation, "_first_read_profit_years", None) + if years is None: + years = supplied_profit_years(simulation) + if years and not getattr(simulation, "_constructing", False): + simulation._first_read_profit_years = years + return years + + +def _data_year(person, period): + """The year the input self-employment profit was measured. + + - Data: data_profit_year, or with no profit supplied the year simulated. + - A situation: the latest year, up to the one simulated, of those it + gave self_employment_income for: + - as recorded when it was built from a dictionary + (Simulation.build_from_situation), or when it took another + simulation's populations (Simulation.build_from_populations), which + bring that simulation's record, or a data-built one's first year; + - failing one, as supplied when the trading allowance was first + calculated, with a profit supplied, after the simulation was + built: a situation built without a profit, or given one only for + later years, reads the profits set before then, in whatever order, + as a dictionary giving them all would. + A profit written later (after the build for the first, after that + calculation for the second), such as a scenario modifier's or a + derivative's, does not move the year. A branch reads the record of + the simulation it was made from, so its own writes never move it + either. A profit carried from + an earlier year keeps that year's allowance: a 2025-26 profit + simulated in 2027-28 deducted £1,000, and gains from the raise to + £3,000. The builder stores a year's inputs for everyone in the + situation, giving anyone not set the default, so one year serves + them all. + Failing both, the year simulated. + + Not handled: + - data cannot declare the year their incomes were measured in, so a + file saved from a later projected year, or a situation saved over + several years, is read as measured in its first year; + - a write before that first calculation counts as supplied, such as a + derivative's on a situation not yet given a profit, or one made on a + copy (Simulation.clone, as policyengine-core's derivative makes) + before the original was calculated. + """ + year = period.start.year + simulation = branch_root(person.simulation) if built_from_data(simulation): - # Checked by type: a policyengine-core Dataset loads any attribute - # it is asked for as a table, so it cannot be asked for years. - dataset = getattr(simulation, "dataset", None) - if isinstance(dataset, UKMultiYearDataset): - return min(dataset.years) - supplied = supplied_input_periods(person, "self_employment_income") - return (supplied[0] if supplied else period).start.year - recorded = getattr(simulation, "_situation_profit_years", None) or () - earlier = [year for year in recorded if year <= period.start.year] - if not earlier: - earlier = [ - supplied_period.start.year - for supplied_period in supplied_input_periods( - person, "self_employment_income" - ) - if supplied_period.start.year <= period.start.year - ] - return earlier[-1] if earlier else period.start.year + data_year = data_profit_year(simulation) + return year if data_year is None else data_year + # Taken at the first calculation even where the build's record answers, + # so that later writes stay out of it. + first_read = _profit_years_when_first_read(simulation) + for years in ( + getattr(simulation, "_situation_profit_years", None) or (), + first_read, + ): + earlier = [recorded for recorded in years if recorded <= year] + if earlier: + return earlier[-1] + return year def trading_allowance_reliefs(person, period, parameters): @@ -146,18 +198,19 @@ def trading_allowance_reliefs(person, period, parameters): default share a claimant with no loss keeps the allowance at or above the input allowance, where the deduction is its excess over the input allowance, capped at profit, and a cut leaves their profit as - reported, the lower bound of its yield; a claimant with a loss claims - it until the allowance saves more. The election weighs the loss - against the saving in taxable trade profit, so other figures can step - where it switches, under the convention every status shares: Class 4, - where losses brought forward absorb that saving and any supplied - current loss wins; and income tax itself where a relief reads trade - profit, such as pension relief limited by relevant UK earnings or the - s. 24A cap on loss relief. The deduction - is negative where it - gives back capital allowances or charges what a cut adds. Their - receipts take up the allowance before income not otherwise charged, - as other receipts do. + reported, the lower bound of its yield. A claimant with a loss claims + it only where it is worth more than the allowance's saving, ties going + to the allowance, and the share sets that saving at any allowance, the + input allowance included. The election weighs the loss against the + saving in taxable trade profit and income not otherwise charged, so + other figures can step where it switches, under the convention every + status shares: Class 4, where losses brought forward absorb that + saving and any supplied current loss wins; and income tax itself + where a relief reads trade profit, such as pension relief limited by + relevant UK earnings or the s. 24A cap on loss relief. The deduction + is negative where it gives back capital allowances or charges what a + cut adds. Their receipts take up the allowance before income not + otherwise charged, as other receipts do. - A non-claimant does not elect for partial relief, which would be a change in behaviour after a raise, so takes ordinary treatment unless their relevant income is within the allowance: full relief needs no 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 699d89b899..cd2dd9593e 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py @@ -23,9 +23,9 @@ class trading_allowance_deduction(Variable): "deducts the allowance legislated for the year the input data were " "measured (a dataset's first year, or in a situation the latest " "year, up to the one simulated, of those the situation gave the " - "profit for when it was built, first given a profit or took its " - "populations from another simulation; failing one, the years a profit " - "is supplied for), " + "profit for when it was built or took its populations from another " + "simulation; failing one, those a profit was supplied for when the " + "allowance was first calculated), " "so their receipts are their profit plus it, whatever " "receipts are supplied, and the deduction is the allowance's excess " "over it, capped at profit. It also gives back any capital allowances " diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py index 390e5a8294..9997c6a518 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_trading_allowance.py @@ -1,6 +1,12 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import stored_as_input from policyengine_uk.utils.trading_allowance import trading_allowance_reliefs +DEDUCTIONS = ( + "trading_allowance_deduction", + "miscellaneous_income_trading_allowance_deduction", +) + class uses_trading_allowance(Variable): value_type = bool @@ -20,7 +26,13 @@ class uses_trading_allowance(Variable): "(trading_allowance_claim_status) counts whenever they take it, even " "where it relieves nothing beyond their input profit. By default, " "with no current loss, they take it at any allowance at or above the " - "one their profit already deducts." + "one their profit already deducts. That is the deductions' own " + "election, so it counts only where both come from their formulas: " + "where either is an input (supplied, or set by the model) or " + "neutralised, the deductions applied are not the election's, and " + "only a positive one counts, for a claimant as for anyone. A reform " + "that replaces either deduction's formula should replace this one's " + "too." ) definition_period = YEAR reference = [ @@ -39,11 +51,26 @@ class uses_trading_allowance(Variable): ] def formula(person, period, parameters): + uses = (person("trading_allowance_deduction", period) > 0) | ( + person("miscellaneous_income_trading_allowance_deduction", period) > 0 + ) + # A claimant with a profit can take the allowance with a deduction + # of nil or less. Only the deductions' formulas make that election, + # so it counts only where the deductions applied are theirs. Without + # such a claimant there is nothing to add, and the reliefs are not + # worked out a third time. + status = person("trading_allowance_claim_status", period) + claimant = (status == status.possible_values.CLAIMANT) & ( + person("self_employment_income", period) > 0 + ) + system = person.simulation.tax_benefit_system + if not claimant.any() or any( + system.get_variable(name).is_neutralized + or stored_as_input(person, name, period) + for name in DEDUCTIONS + ): + return uses _, _, claimant_takes_allowance = trading_allowance_reliefs( person, period, parameters ) - return ( - (person("trading_allowance_deduction", period) > 0) - | (person("miscellaneous_income_trading_allowance_deduction", period) > 0) - | claimant_takes_allowance - ) + return uses | claimant_takes_allowance 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 4d0f62e334..e74175cb1d 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 @@ -56,9 +56,11 @@ def formula(person, period, parameters): if loss is None: return person.empty_array() # Class 4 uses the same year's recomputed trade profit or loss as - # Income Tax. A positive allowance deduction, on trade profits or on - # income not otherwise charged, means the taxpayer chose allowance - # treatment and gave up ordinary current losses. + # Income Tax. A taxpayer who chose allowance treatment gave up + # ordinary current losses: one with a positive allowance deduction, + # on trade profits or on income not otherwise charged, or a claimant + # (trading_allowance_claim_status) who chose it with a deduction of + # nil or less (uses_trading_allowance). # This leaves independently carried earlier Class 4 losses intact. allowance_chosen = person("uses_trading_allowance", period) return where(allowance_chosen, 0, max_(loss, 0)) diff --git a/policyengine_uk/variables/input/trading_allowance_claim_status.py b/policyengine_uk/variables/input/trading_allowance_claim_status.py index 1e2459e3fb..0a4543198f 100644 --- a/policyengine_uk/variables/input/trading_allowance_claim_status.py +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -23,14 +23,14 @@ class trading_allowance_claim_status(Variable): "a file saved from a later projected year is read as measured in its " "first year. In a situation it is the latest year, up to the one " "simulated, of those the situation gave self_employment_income for, " - "as recorded when it was built, or, if it gave none then, when a " - "profit is first set; populations taken from another simulation " - "bring its record, or a dataset's first year. So a profit carried " - "from an earlier year keeps that year's allowance, whatever year " - "this status was given for, and a profit set after the record does " - "not move it. Where no recorded year is at or before the one " - "simulated, the years a profit is supplied for count. " - "The model reads the " + "as recorded when it was built (populations taken from another " + "simulation bring its record, or a dataset's first year), or, " + "failing one, as supplied when the allowance was first calculated " + "after the build. So a profit carried from an earlier year keeps " + "that year's allowance, whatever year this status was given for, " + "and a profit set after those points, by a modifier or a derivative, " + "does not move it; one set before the first calculation counts, in " + "whatever order. The model reads the " "allowance legislated for that year, which reforms do not move. The " "status describes a positive profit: a person with no " "profit has no trade receipts for it to describe, and a claimant is " @@ -62,7 +62,10 @@ class trading_allowance_claim_status(Variable): "allowance as receipts within it. For profits already net of the " "allowance, mark claimants and non-claimants here rather than set " "trading_allowance_deduction to zero, which keeps any change to the " - "allowance from reaching them." + "allowance from reaching them. Where either trading allowance " + "deduction is an input or neutralised, a claimant's election does " + "not mark them as taking the allowance (uses_trading_allowance): the " + "deductions applied are not its." ) definition_period = YEAR reference = [