diff --git a/.github/workflows/test-suite.yaml b/.github/workflows/test-suite.yaml index 0ea9cd70c6..55615ba17d 100644 --- a/.github/workflows/test-suite.yaml +++ b/.github/workflows/test-suite.yaml @@ -87,9 +87,11 @@ 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). 75 minutes matches the Python - # shards, which Result waits for anyway, until the cases are released. + # 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 @@ -185,7 +187,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 new file mode 100644 index 0000000000..7d849ccad3 --- /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 the allowance legislated for the year the profit was measured (a dataset's first year, or for a situation the year simulated, as the State Pension variables read them), 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 a trading allowance deduction is an input or neutralised, a claimant's election weighs it at the value applied and the other deduction follows that election, so pinning a deduction to the value its formula gives changes nothing. 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..e25c52465c --- /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: on 1 November shards took up to 75 minutes on slower runners and were cancelled with no test failing. 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..5d2478c438 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/trading_allowance_claimant_expense_share.yaml @@ -0,0 +1,34 @@ +description: >- + Actual expenses and capital allowances of a trading allowance claimant, as a + 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 +# taxable profit as reported: the lower bound of its yield. With 0 they have +# 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: + 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/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..c7f9230d43 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/trading_allowance_claim_status.yaml @@ -0,0 +1,922 @@ +# 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 profit was measured: a dataset's first year, or +# in a situation like these 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 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. +# - Unknown (the default): unchanged. +# The allowance is £1,000 in 2026-27 and £3,000 from 2027-28 (Budget 2026, +# 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 + 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 + # 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 + 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: 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 + 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: 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: + # 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: 2026 + 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: 2026 + 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 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 + 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 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: 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 + 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] + uses_trading_allowance: [false, false] + +- name: A claimant's capital allowances are not deducted. + period: 2026 + absolute_error_margin: 0 + input: + self_employment_income: 2_500 + capital_allowances: 500 + trading_allowance_claim_status: CLAIMANT + output: + # 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: 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 + 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 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 + input: + self_employment_income: 600 + trading_allowance_claim_status: NON_CLAIMANT + output: + # 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 + +- 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 supplied receipts above the allowance give no switching gain. + 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 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 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 + 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: 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 + 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: 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: + 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: 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: + 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: At the input allowance a claimant claims a current loss instead. + period: 2026 + 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: + # 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: 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 beats a claimant's smaller current 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 + 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 + 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's current loss larger than the allowance's saving is claimed instead. + 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: + # 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 + 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: 1_500 + # (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: 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: 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 + 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: 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 + 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 + 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 + 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 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 (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: 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 + 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 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 pinned nil applies whichever treatment they elect, so their + # election weighs it as the allowance's: it saves nothing over their + # £1,000 of expenses, and the loss is 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 election weighs a pinned casual-income deduction at its value. + 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 deduction is pinned at nil, so the allowance saves + # only the 500 of trade profit over their expenses, less than the loss. + # They take ordinary treatment throughout: their £1,000 of expenses, + # with the capital allowances given back (500 - 1,000 = -600, deducted + # separately), the loss claimed and the casual income taxed in full. + trading_allowance_deduction: -600 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 800 + # (30,000 + 500 + 1,000 - 800 - 12,570) x 20% + income_tax: 3_626 + +- name: A deduction pinned to the value its formula gives changes nothing. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.simulation.trading_allowance_claimant_expense_share: 0.5 + employment_income: 30_000 + self_employment_income: 5_000 + trading_loss: 400 + trading_allowance_claim_status: CLAIMANT + trading_allowance_deduction: 0 + output: + # Unpinned, the deduction is nil too: the allowance saves 500 over their + # £500 of expenses, more than the £400 loss, so they take it and give + # the loss up. Pinning that nil changes neither the election nor tax. + uses_trading_allowance: true + trade_loss_relief_against_general_income: 0 + # (30,000 + 5,000 - 12,570) x 20% + income_tax: 4_486 + +- name: A pinned trade deduction leaves no casual-income relief to cost a claimant 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 + miscellaneous_income: 600 + income_not_otherwise_charged: 600 + trading_loss: 800 + trading_allowance_claim_status: CLAIMANT + trading_allowance_deduction: 0 + output: + # The pinned nil saves nothing on the trade, and the casual-income + # relief of 600 is less than the loss, so the loss is claimed and the + # casual income taxed, as at £1,000. + miscellaneous_income_trading_allowance_deduction: 0 + uses_trading_allowance: false + trade_loss_relief_against_general_income: 800 + # (30,000 + 500 + 600 - 800 - 12,570) x 20% + income_tax: 3_546 + +- name: A positive pinned deduction means a claimant takes the allowance. + 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 + miscellaneous_income: 400 + income_not_otherwise_charged: 400 + trading_loss: 3_000 + trading_allowance_claim_status: CLAIMANT + trading_allowance_deduction: 2_000 + output: + # The pinned 2,000 relieves the trade, so the allowance applies, and the + # casual income gets what receipts of 1,500 leave of £3,000, though the + # loss is larger than the saving. + miscellaneous_income_trading_allowance_deduction: 400 + uses_trading_allowance: true + trade_loss_relief_against_general_income: 0 + # (30,000 - 12,570) x 20%: the pinned 2,000 leaves no trade profit, and + # the casual income is relieved in full. + taxable_self_employment_income: 0 + income_tax: 3_486 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..53756fec21 --- /dev/null +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_data_year.py @@ -0,0 +1,474 @@ +"""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, and for +anything else, such as a situation, the year simulated, as the State Pension +variables read them. These tests build small datasets 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). They also check that a situation's data year does not depend on how +or when its profit was given, and that a deduction which does not come from +its formula enters the claimant's election at the value applied. +""" + +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_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=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): + 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_situation_reads_its_profit_as_measured_in_the_year_simulated(): + # A 2027 profit already deducts 2027-28's £3,000, and a 2026 profit + # 2026-27's £1,000, which a raise to £3,000 then exceeds by £2,000. + later = Simulation( + situation=situation( + { + "age": {2027: 40}, + "self_employment_income": {2027: 5_000}, + "trading_allowance_claim_status": {2027: "CLAIMANT"}, + } + ) + ) + assert deduction(later, 2027) == [0] + person = { + "age": {2026: 40}, + "self_employment_income": {2026: 5_000}, + "trading_allowance_claim_status": {2026: "CLAIMANT"}, + } + assert deduction(Simulation(situation=situation(person)), 2026) == [0] + raised = Simulation( + situation=situation(person), reform={ALLOWANCE: {"2026": 3_000}} + ) + assert deduction(raised, 2026) == pytest.approx([2_000]) + + +def test_a_situation_reads_a_carried_profit_as_measured_in_the_year_simulated(): + # The documented limit: a profit given for 2025 and carried into 2027 is + # read as net of 2027-28's £3,000, so the raise gives it nothing, where + # the same record in a dataset measured in 2025 gains £2,000. + carried = Simulation( + situation=situation( + { + "age": {2025: 40}, + "employment_income": {2025: 30_000}, + "self_employment_income": {2025: 5_000}, + "trading_allowance_claim_status": {2025: "CLAIMANT"}, + } + ) + ) + assert deduction(carried, 2027) == [0] + assert deduction(Simulation(dataset=traders(2025)), 2027)[0] == pytest.approx(2_000) + + +def claimants(*profits): + """Claimants from 2026 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": {2026: 40}, + "employment_income": {2026: 30_000}, + "trading_allowance_claim_status": {2026: "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_a_situations_data_year_does_not_depend_on_how_its_profit_was_given(): + # Profits for each year from 2026 to 2030, given in the dictionary or + # set one year at a time in any order, and two people's set in turn, + # all read each year's profit as measured in that year. + import numpy as np + + years = range(2026, 2031) + given = Simulation(situation=claimants({year: 5_000 for year in years})) + tax = given.calculate("income_tax", 2027)[0] + assert deduction(given, 2027) == [0] + for order in (years, reversed(years)): + 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) + pair = Simulation(situation=claimants({}, {})) + pair.set_input("self_employment_income", 2027, np.array([0.0, 5_000.0])) + pair.set_input("self_employment_income", 2026, np.array([5_000.0, 0.0])) + assert deduction(pair, 2027) == deduction( + Simulation(situation=claimants({2026: 5_000}, {2027: 5_000})), 2027 + ) + + +def test_derivatives_branches_and_modifiers_leave_a_situations_data_year(): + import numpy as np + + from policyengine_uk.dynamics.progression import calculate_derivative + from policyengine_uk.utils.scenario import Scenario + + # A derivative of the profit, this package's or policyengine-core's, + # and whether or not anything was calculated before it. + def claimant(): + return Simulation(situation=claimants({2027: 5_000})) + + arguments = dict(input_variable="self_employment_income", year=2027, count_adults=1) + assert calculate_derivative(claimant(), **arguments).tolist() == ( + calculate_derivative(claimant(), **arguments).tolist() + ) + first = claimant().derivative("income_tax", "self_employment_income", 2027) + calculated = claimant() + calculated.calculate("income_tax", 2027) + later = calculated.derivative("income_tax", "self_employment_income", 2027) + assert first.tolist() == pytest.approx(later.tolist()) + # A branch given the profit reads it as a simulation given it does. + branch = Simulation(situation=claimants({})).get_branch("raise") + branch.set_input("self_employment_income", 2025, np.array([5_000.0])) + alone = Simulation(situation=claimants({})) + alone.set_input("self_employment_income", 2025, np.array([5_000.0])) + assert deduction(branch, 2027) == deduction(alone, 2027) + # A modifier that writes a profit back changes nothing. + + def write_back(simulation): + simulation.set_input( + "self_employment_income", + 2025, + simulation.calculate("self_employment_income", 2025), + ) + + reformed = Simulation( + situation=claimants({2027: 5_000}), + scenario=Scenario(simulation_modifier=write_back), + ) + assert reformed.calculate("income_tax", 2027)[0] == pytest.approx( + reformed.baseline.calculate("income_tax", 2027)[0] + ) + + +def test_a_situation_taking_a_datasets_populations_reads_the_year_simulated(): + # The documented limit: only a simulation built from the data reads + # their first year, so a situation that takes a dataset's populations + # reads the year simulated, where the dataset gives £2,000. + data = Simulation(dataset=traders(2025)) + simulation = Simulation(situation=situation({"age": {2026: 40}})) + simulation.build_from_populations(data.populations) + assert deduction(simulation, 2027)[0] == 0 + assert deduction(data, 2027)[0] == pytest.approx(2_000) + + +def test_a_household_subset_of_data_reads_their_first_year(): + # A subset is a branch of the data's simulation, so it reads the + # dataset's first year, as the whole population does. + import numpy as np + + from policyengine_uk.utils.household_subset import household_subset + + data = Simulation(dataset=traders(2025)) + with household_subset(data, "claimant", np.array([True, False, False])) as subset: + assert subset.person is not None + assert subset.simulation.calculate( + "trading_allowance_deduction", 2027 + ).tolist() == pytest.approx([2_000]) + + +def test_a_raise_never_withdraws_a_situation_claimants_carried_loss(): + # A 2025 loss carried into 2026 is not a supplied 2026 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 adds its £2,000. + person = { + "age": {2026: 40}, + "employment_income": {2026: 30_000}, + "self_employment_income": {2026: 5_000}, + "trading_loss": {2025: 5_000}, + "trading_allowance_claim_status": {2026: "CLAIMANT"}, + } + law = Simulation(situation=situation(person)) + raised = Simulation( + situation=situation(person), reform={ALLOWANCE: {"2026": 3_000}} + ) + assert raised.calculate("uses_trading_allowance", 2026).tolist() == [True] + assert raised.calculate("income_tax", 2026)[0] == pytest.approx( + law.calculate("income_tax", 2026)[0] - 400, abs=1 + ) + + +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 + + 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, + ) + 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 test_a_neutralised_deduction_keeps_a_raise_from_costing_a_claimant_their_loss(): + # The deduction applied is nil whatever the claimant elects, so their + # election weighs it as the allowance's: it saves nothing over their + # expenses, and they keep their £1,500 loss under a raise to £3,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]) + + +def test_a_deduction_the_model_sets_is_weighed_at_its_value(): + # A value a scenario stores as the model's (set_model_value) applies like + # a supplied one: pinned at nil under a raise to £3,000, it saves nothing + # over the claimant's expenses, so they keep their £1,500 loss. + import numpy as np + + from policyengine_uk.utils.supplied_inputs import set_model_value + + 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={ALLOWANCE: {"2026": 3_000}} + ) + set_model_value(simulation, "trading_allowance_deduction", 2026, np.array([0.0])) + assert simulation.calculate("uses_trading_allowance", 2026).tolist() == [False] + # (30,000 + 2,500 - 1,500 - 12,570) x 20% + assert simulation.calculate("income_tax", 2026).tolist() == pytest.approx([3_686]) + + +DEDUCTIONS = ( + "trading_allowance_deduction", + "miscellaneous_income_trading_allowance_deduction", +) +COMPARED = DEDUCTIONS + ( + "taxable_self_employment_income", + "taxable_miscellaneous_income", + "uses_trading_allowance", + "trade_loss_relief_against_general_income", + "ni_class_4", + "income_tax", +) + + +def grid_situation(): + """One person for each status, profit, capital allowances, casual income + and current loss, all for 2026, each in their own household.""" + import itertools + + people = {} + for index, (status, profit, capital, casual, loss) in enumerate( + itertools.product( + ["CLAIMANT", "UNKNOWN", "NON_CLAIMANT"], + [500, 2_500, 5_000], + [0, 600], + [0, 600, 1_000], + [0, 400, 800, 1_500], + ) + ): + people[f"person_{index}"] = { + "age": {2026: 40}, + "employment_income": {2026: 30_000}, + "self_employment_income": {2026: profit}, + "capital_allowances": {2026: capital}, + "miscellaneous_income": {2026: casual}, + "income_not_otherwise_charged": {2026: casual}, + "trading_loss": {2026: loss}, + "trading_allowance_claim_status": {2026: status}, + } + return { + "people": people, + "benunits": {f"b_{name}": {"members": [name]} for name in people}, + "households": {f"h_{name}": {"members": [name]} for name in people}, + } + + +@pytest.mark.parametrize("allowance,share", [(1_000, 0.5), (3_000, 1.0), (300, 0.5)]) +def test_a_deduction_pinned_to_its_formulas_value_changes_nothing(allowance, share): + # Whichever deductions are pinned, supplied or set by the model, to the + # values their formulas give, every status's election, deductions and + # taxes stay as they are. + from policyengine_uk.utils.supplied_inputs import set_model_value + + reform = { + ALLOWANCE: {"2026": allowance}, + "gov.simulation.trading_allowance_claimant_expense_share": {"2026": share}, + } + unpinned = Simulation(situation=grid_situation(), reform=reform) + expected = {name: unpinned.calculate(name, 2026) for name in COMPARED} + for pinned, by_model in ( + (DEDUCTIONS[:1], False), + (DEDUCTIONS[1:], False), + (DEDUCTIONS, False), + (DEDUCTIONS[:1], True), + ): + simulation = Simulation(situation=grid_situation(), reform=reform) + for name in pinned: + if by_model: + set_model_value(simulation, name, 2026, expected[name]) + else: + simulation.set_input(name, 2026, expected[name]) + for name in COMPARED: + actual = simulation.calculate(name, 2026) + assert ( + actual.tolist() == expected[name].tolist() + if actual.dtype == bool + else actual.tolist() == pytest.approx(expected[name].tolist(), abs=0.01) + ), (name, pinned, by_model) 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..d815106fbd --- /dev/null +++ b/policyengine_uk/tests/test_trading_allowance_claim_status_properties.py @@ -0,0 +1,377 @@ +"""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 a positive profit. A claimant's profit already deducts the +allowance legislated for the year the profit was measured (for a situation, +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), 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, 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, 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. +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, nor Class 4 + profits, for any status. +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 +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 = 2026 +# s. 783AD, the allowance for 2026-27, the year these situations are measured. +INPUT_ALLOWANCE = 1_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)] + # A share above 1 would put expenses above the allowance claimed. + + [(1_000, 1.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, 300.0, 500.0, 999.99, 1_000.0, 2_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, 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 +) +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, 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 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}, + "gov.simulation.trading_allowance_claimant_expense_share": { + str(year): share + }, + } + ) + return simulation + + +def run(sim, 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)], + 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, 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() + + # 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"), + 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, 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"] + 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) + # 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, + ) + # 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, 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 + 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. 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.where(claimant, with_allowance, np.minimum(with_allowance, ordinary)), + ordinary, + ) + 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, 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 + 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) + takes_allowance = np.where( + claimant, + 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 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 + # 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) + 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 + | (~claimant & (np.abs(advantage) <= margin)), + ) + + +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_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 = 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"] + 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) + # 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) + 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 and share == 1: + reported = np.maximum(r["profit"] - r["brought_forward"], 0) + assert ( + np.abs(r["taxable"] - reported)[claimant] + <= tolerance(r["profit"])[claimant] + ).all(), allowance + # 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) + 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 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))[ + no_loss + ].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 = 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 = 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( + "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 38897f81bf..c04b516bcb 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", @@ -76,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 d16a3b54f0..b80c8cb0b8 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 @@ -9,17 +10,81 @@ 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.supplied_inputs import supplied_input +from policyengine_uk.utils.data_source import built_from_data +from policyengine_uk.utils.supplied_inputs import ( + stored_as_input, + supplied_input, + supplied_input_periods, +) + +TRADE_DEDUCTION = "trading_allowance_deduction" +MISCELLANEOUS_DEDUCTION = "miscellaneous_income_trading_allowance_deduction" + + +def _applied_deduction(person, name, period): + """The value deduction ``name`` applies where its formula does not give + it: an input (supplied, or set by the model), or a neutralised + variable's default. None where the formula gives it. + + SUPPLIED_INPUT_VARIABLES lists both deductions, as stored_as_input + requires.""" + variable = person.simulation.tax_benefit_system.get_variable(name) + if variable.is_neutralized or stored_as_input(person, name, period): + return person(name, period) + return None + + +def _data_year(person, period): + """The year the input self-employment profit was measured, whose + allowance a claimant's profit already deducts. + + - 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. + - Anything else, such as a situation: the year simulated, as the State + Pension variables read a situation. + + Not handled: + - data cannot declare the year their incomes were measured, so a file + saved from a later projected year is read as measured in its first + year; + - a situation's profit is read as measured in the year simulated, so a + profit given for an earlier year and carried forward is read as net + of the later year's allowance. A claimant's 2025-26 profit simulated + in 2027-28 then gains nothing from the raise to £3,000, where the + same record in a dataset measured in 2025 gains £2,000. Give a + situation's profit for the year simulated, net of that year's + allowance, or use a dataset. A simulation that takes a data-built + simulation's populations (build_from_populations) is read the same + way. + """ + from policyengine_uk.data import UKMultiYearDataset + + simulation = person.simulation + if not built_from_data(simulation): + return period.start.year + # 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 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,6 +125,58 @@ 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 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 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 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, 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 + 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 deduction that does not + come from its formula (an input, supplied or set by the model, or a + neutralised variable) applies at its own value whichever treatment + they elect, so their election weighs it as the allowance's, and the + other deduction follows that election: a pin to the value the formula + gives changes nothing, and a positive one means the allowance + applies. For everyone else the election is as before this status + existed, from the formulas' values. + - 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). The model holds no expenses against it. Not modelled: the exclusions in @@ -67,10 +184,38 @@ 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 + # 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 + 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), + round_(claimant_capital_allowances, 2), + ), + input_allowance, + ) + 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) @@ -82,7 +227,6 @@ def trading_allowance_reliefs(person, period, parameters): 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) - # 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( @@ -90,6 +234,20 @@ def trading_allowance_reliefs(person, period, parameters): receipts, where(profit <= allowance, max_(profit, 0), allowance), ) + # 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_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), @@ -107,19 +265,82 @@ 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 deduction that does not come from its formula (an input, or + # neutralised) applies at its own value whichever treatment the claimant + # elects. So their election weighs it as the allowance's, and the other + # deduction follows that election: a pin to the value the formula gives + # changes nothing. A positive one means the allowance applies. + applied_trade = _applied_deduction(person, TRADE_DEDUCTION, period) + applied_miscellaneous = _applied_deduction(person, MISCELLANEOUS_DEDUCTION, period) + elected_trade_relief = ( + claimant_allowance_relief + if applied_trade is None + else applied_trade.astype(float) + ) + elected_miscellaneous_relief = ( + miscellaneous_relief if applied_miscellaneous is None else applied_miscellaneous + ) + applied_positive = person.filled_array(False) + for applied in (applied_trade, applied_miscellaneous): + if applied is not None: + applied_positive = applied_positive | (applied > 0) + # 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 - elected_trade_relief, 0) + + elected_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) 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 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) + & (allowance >= claimant_expenses) + & ~(current_loss > claimant_saving) + ) + | applied_positive + ) return ( - where(claim_current_loss, 0, trade_relief), - where(claim_current_loss, 0, miscellaneous_relief), + 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/miscellaneous_income_trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/miscellaneous_income_trading_allowance_deduction.py index 8b48624fdc..ba6b1bac80 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,13 +20,20 @@ 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. 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, which a claimant's election then weighs at nil. 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 = [ @@ -50,5 +57,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 565a4957d9..0348bfae00 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,25 @@ 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. 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 for a situation the year " + "simulated), " + "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 ordinary treatment deducts the expenses " + "that share sets, or their capital allowances if larger, and their " + "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. " @@ -60,5 +78,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..0c232cdbba 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,17 @@ 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) 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. Where a deduction is an input " + "(supplied, or set by the model) or neutralised, their election " + "weighs it at the value applied, and the other deduction follows " + "that election, so a deduction pinned to the value its formula gives " + "changes nothing; a positive one means the allowance applies. A " + "reform that replaces either deduction's formula should replace this " + "one's too." ) definition_period = YEAR reference = [ @@ -34,6 +45,20 @@ class uses_trading_allowance(Variable): ] def formula(person, period, parameters): - return (person("trading_allowance_deduction", period) > 0) | ( + 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, which only their election shows. 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 + ) + if not claimant.any(): + return uses + _, _, claimant_takes_allowance = trading_allowance_reliefs( + person, period, parameters + ) + 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/income_not_otherwise_charged.py b/policyengine_uk/variables/input/income_not_otherwise_charged.py index 615a6ec09a..37bb8641e2 100644 --- a/policyengine_uk/variables/input/income_not_otherwise_charged.py +++ b/policyengine_uk/variables/input/income_not_otherwise_charged.py @@ -19,7 +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." + "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 c7ff04c188..9b3c943a82 100644 --- a/policyengine_uk/variables/input/self_employment_gross_receipts.py +++ b/policyengine_uk/variables/input/self_employment_gross_receipts.py @@ -10,7 +10,10 @@ 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. " + "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 new file mode 100644 index 0000000000..27cfd093ca --- /dev/null +++ b/policyengine_uk/variables/input/trading_allowance_claim_status.py @@ -0,0 +1,81 @@ +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. For microdata the year is the dataset's first year, which " + "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. For anything else, such as a situation, it is the year " + "simulated, as the State Pension variables read it, so give a " + "situation's profit for the year simulated, net of that year's " + "allowance: a profit given for an earlier year and carried forward " + "is read as net of the later year's (a 2025-26 claimant's profit " + "simulated in 2027-28 gains nothing from the raise to £3,000, which " + "the same record in a dataset measured in 2025 gains). A simulation " + "that takes a data-built simulation's populations is read the same " + "way. 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. " + "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. 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, 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, " + "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. Where a trading allowance deduction " + "is an input (supplied, or set by the model) or neutralised, a " + "claimant's election weighs it at the value applied, and the other " + "deduction follows that election, so a deduction pinned to the value " + "its formula gives changes nothing; a positive one means the " + "allowance applies." + ) + 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", + ), + ]