Charge Class 4 NICs on Chapter 2 profits, after capital allowances, the trading allowance and Schedule 2 losses - #1919
Conversation
Class 4 was charged on raw self_employment_income. It now uses ni_class_4_profits: profit after capital allowances and the trading allowance (ITTOIA 2005 Part 2 Chapter 2), less trading losses as SSCBA 1992 Sch. 2 para. 3 allows, carried forward without limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…test Addresses the independent review: documents trading_loss carry-over, that negative profit creates no loss (as for income tax), unmodelled carry-backs and para. 3(5) deductions, pooling across trades, and the s. 83(4) order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Reviewed. The approach is right, with one issue I'd fix before merging. It matches SSCBA 1992 s.15(1)(b) and Sch 2 para 3: Class 4 on Chapter 2 profits, after capital allowances, the trading allowance and the losses para 3 allows, and nothing is deducted twice. Hand probes matched: partial trading allowance relief; £20k profit less a £5k loss giving £15k and £145.80; a loss carried forward from 2014. Locally, NI and income tax YAML pass (227), the property modules pass (29) and the policy YAML suite passes (1,292).
|
Resolve the national-insurance notebook conflict by keeping main's text (Class 1 age limits) and re-applying this branch's Class 4 wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Reviewed commit Outstanding loss-persistence concern. The carry-forward loop at ni_class_4_losses_brought_forward.py:54 reads Repair and acceptance steps:
Suggested post-repair checks: uv run --no-sync pytest policyengine_uk/tests/test_ni_class_4_profits_properties.py policyengine_uk/tests/test_ni_class_4_properties.py policyengine_uk/tests/test_ni_class_4_maximum.py -q
uv run --no-sync policyengine-core test policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance -c policyengine_ukNo new execution occurred during this audit. The historical probe and passing suites belong to the linked prior review. The repaired one-off case, interaction with Income Tax loss handling, current-main conflict resolution and fresh hosted CI remain unverified. Live posting check (2026-10-02T15:12:35.823669+00:00): same reviewed commit; GitHub reports |
Brings in #1896 (generic child/adult flags removed) and later main commits. No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trading_loss carries into later years that are not set, so a one-off loss was deducted from Class 4 profits again in every later year. The new ni_class_4_trading_loss is the loss supplied for the year itself; relief, the carry-forward and the brought-forward fold read it, and the fold reads supplied values only, so its result no longer depends on what was calculated first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UK system does not call core's add_abolition_parameters (it has not since #1274), so a supplied input can only be disabled by neutralizing its variable. Keep that check and drop the abolition lookup and its test, which applied a reform to a parameter that does not exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e test reference A plain clone shared the original's record of supplied inputs, so a loss supplied on the clone made the original count its own carried-over loss again. UK simulations now copy that record when cloning and drop it when arrays are deleted, and the helpers ignore a key with no stored array. The property reference reads each year's trading allowance, which is nil before 2017-18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Holder.delete_arrays removes a stored value but leaves core's record of it as an input (policyengine-core#559). The engine could then carry an earlier loss into the emptied year, and the stale record made Class 4 count that carried value as a second loss. Simulation.calculate now drops such records for the variables the supplied-input helpers read before the engine can refill the period, and the helpers refuse any variable outside that set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged under decision d708 (merge after #1881 once CI is green on the head, an independent review approves it and the impact is stated) and Max's PE-UK merge-on-gates rule.
|
Fixes #1916
#1881 (the trading allowance fix, which adds
self_employment_gross_receipts) has merged, so this PR's diff is its own change.What was wrong
ni_class_4,ni_class_4_mainandni_class_4_maximumtook profits as rawself_employment_income. That ignores capital allowances, trading losses and, when receipts are supplied, the trading allowance. #1886 already removed the Class 1 deduction, and this PR does not bring it back.The law
The fix
ni_class_4_profits_before_losses(new)max(0, self_employment_income − capital_allowances − trading_allowance_deduction). These are the Chapter 2 profits.trading_allowance_deduction(#1881) is already net of expenses and capital allowances, so the two never stack.ni_class_4_trading_loss(new)trading_losswhere one is supplied for that year, and nil otherwise.trading_lossis an input the engine carries into later years that are not set; for Class 4 a loss is relieved once, so a carried-over value is not a new loss.ni_class_4_loss_relief(new)min(ni_class_4_trading_loss + losses brought forward, profits before losses)ni_class_4_losses_brought_forward(new)policyengine_uk/utils/supplied_inputs.py, which uses the engine's record ofset_inputcalls), so the result does not depend on what was calculated first. It can also be supplied as an input for any year, and later years build on that balance.ni_class_4_losses_carried_forward(new)ni_class_4_profits(new)ni_class_4,ni_class_4_main,ni_class_4_maximumni_class_4_profitsin place ofself_employment_income. #1884's float32 handling and regulation 100 steps are unchanged.Personal reliefs, interest relief and pension contributions are not deducted (para. 3(2)). Income tax is untouched.
Why a loop and not a formula on the previous year. The first version recursed through
period.last_year. The engine's spiral guard (max_spiral_loops = 10) returns the default past ten levels, so a 2016 loss simply vanished from 2030. The fold has no self-reference, so there is no limit. It stops at the first year for which any loss data is known, so microsimulation runs one extra year at most.test_losses_carry_forward_over_many_yearschecks up to 16 years, with the years calculated in random order.Invariants (stated and property-tested)
policyengine_uk/tests/test_ni_class_4_profits_properties.pyuses Hypothesis. Each example simulates 24 cases twice, as drawn and varied, over two years, for 2022–23 and 2025–26. The draws are weighted towards the £1,000 allowance, the LPL and the UPL. The reference is an independent exact-rational (Fraction) implementation of the statute.trading_loss + brought forward = relief + carried forward. Brought forward equals last year's carried forward, and0 ≤ relief ≤ profits before losses.0 ≤ ni_class_4 ≤the s. 15(3) amount on the reference Class 4 profits, which is at most the s. 15(3) amount on unrelieved profit. Where regulation 100 cannot apply,ni_class_4equals the s. 15(3) amount.ni_class_4is non-decreasing in profit, at fixed expenses (or unknown receipts), capital allowances, losses and employment income, wherever Class 2 is unchanged. It is non-increasing in capital allowances and in trading losses.test_ni_class_4_properties.pystill checks s. 15(3) and the exact regulation 100 steps for arbitrary thresholds and rates. It now takesni_class_4_profitsas its input, and asserts that with no reliefs this equals the full profit, or nil within the trading allowance. That keeps #1885's no-Class-1-deduction invariant.Mutation checks. Each mutant below fails the new YAML file, the new property file, or both:
Revenue effect
Real
Microsimulationruns on the Enhanced FRS 2024-25 (policyengine-uk-data 1.57.4, SHA-25603fe15e4…68d4), comparing main5232f9af(which includes #1881) with this branch at9ca5d6eb, which merges that main: no total moves in 2025, 2026, 2027 or 2028. Class 4, National Insurance, income tax and the government balance are unchanged, and no household's net income changes. Mainbfc5fea1fed rawself_employment_incomeinto Class 4; this branch uses adjusted Class 4 profits. Liabilities are equal on this dataset because it has nocapital_allowances,trading_loss,self_employment_gross_receiptsorni_class_4_losses_brought_forwardcolumn, so none of this PR's loss or allowance inputs are present.Known limits (not changed here)
trading_loss. Losses of a trade under trading-allowance full relief are not nilled (s. 783AF), as in Stop stacking the trading allowance on expenses already netted from profit #1881.trading_losscarries over for income tax. It is an input the engine carries to later years that are not set (auto_carry_over_input_variables). Class 4 now counts a loss only in the year it is supplied for (ni_class_4_trading_loss), but income tax'sloss_reliefstill reads the carried value, so a one-off loss recurs there unless later years are set to zero (follow-up).ni_class_2still usesself_employment_income. See follow-up; Class 2 NICs wrong for 2022-23 and 2023-24 (rate, lower profits threshold) and 2015-17 (small profits threshold) #1887 covers other Class 2 issues.self_employment_incomecreates no loss, for Class 4 or for income tax in this model; supply losses throughtrading_loss.ni_class_4_losses_brought_forwardfor one person in a multi-person situation sets it, as zero, for everyone else in that year. This is standard for any formula variable supplied as an input.Tests run
On the current head
9ca5d6eb(main merged, policyengine-core 3.32.13): the four Class 4 and trading allowance test files (42 tests) and the code-health tests pass, 1,830 in all; the 518 YAML cases undertests/policy/baseline/gov/hmrcpass; lint is clean. Before that merge, the review corrections were validated fromfb192e1f13acon core 3.32.9 with all Python commands throughuv run --frozen --extra devand one test process at a time:pytest policyengine_uk/tests/test_ni_class_4_maximum.py policyengine_uk/tests/test_ni_class_4_profits_properties.py policyengine_uk/tests/test_ni_class_4_properties.py policyengine_uk/tests/test_trading_allowance_properties.py -v: 37 passed.pytest policyengine_uk/tests/code_health -q: 1,768 passed.policyengine-core test policyengine_uk/tests/policy/baseline/gov/hmrc -c policyengine_uk: 508 passed.make format: 1,149 files unchanged; lint passed.Earlier validation, retained for context:
On
ddfdfff6(before the last merge of main, which touched no file here):pytest test_ni_class_4_maximum.py test_ni_class_4_profits_properties.py test_ni_class_4_properties.py test_trading_allowance_properties.py tests/code_health: 1,788 passed.policyengine-core test policyengine_uk/tests/policy/baseline/gov/hmrc: 508 passed (oneb4f3c9d), including the Class 4 cases and the new one-off loss case.ruff format --check .andruff check .: clean.Independent review
An Opus 5.5 reviewer ran on a Subfleet
review/standardlane (read-only; it could not execute code) and returned APPROVE. It read s. 15, Sch. 2 paras. 2–3, ITA 2007 s. 83 and the engine source, and hand-checked all 15 YAML expectations, including the reg. 100 Case 3 steps. It also checked the revenue JSON: totals, counts andni_class_4_profits = taxable_self_employment_incomeare identical in every year. Its minor findings were addressed in70cbd58e:trading_lossrecurs in later years — superseded for Class 4 by the current fix. This earlier review assigned the persistence repair to an income-tax follow-up. The current PR now fixes Class 4 itself:ni_class_4_trading_losscounts a loss only in its supplied year, and the loss fold carries forward only the unused balance. Income tax'sloss_reliefstill reads the carried value and remains a follow-up, as described under Known limits.self_employment_income(adjusted_net_incomeuses the flooredtaxable_self_employment_income), so losses come only throughtrading_lossfor both.Finding 7 (the
trading_losslabel) is handled by the newdocumentationfield ontrading_loss.A delta re-review of
70cbd58eon a secondreview/standardlane returned APPROVE. It found every finding fixed or reasonably documented, and it hand-checked the spiral test's arithmetic, including that the values are exact in float32. Its one nit, the Class 2 bullets in the NI docs, predates this PR and belongs with the Class 2 follow-up (#1887).axiom: TheAxiomFoundation/rulespec-uk#379 queued
🤖 Generated with Claude Code
Review of 30 September (vahid-ahmadi) and 2 October (Codex backlog review)
ni_class_4_trading_lossis the loss supplied for the year itself, and relief, the carry-forward and the brought-forward fold read it. The YAML case asked for (a £10,000 loss in 2025 only, £40,000 profit in 2026 and 2027) now gives Class 4 profits of £30,000 then £40,000, and the property tests cover sparse years, calculation order, cache warming and branch inputs. A neutralizedtrading_lossis honoured. (An abolition test was dropped: PolicyEngine-UK builds nogov.abolitionsparameters, so there is no abolition switch to honour.)Review corrections of 3 October
Holder.delete_arrays) keeps core's record of it as an input (policyengine-core#559; core's own fix, policyengine-core#561, is open). The engine could then carry an earlier loss into the emptied year, and the stale record made Class 4 count that carried value as a second loss: the reviewer's case gave £20,000 of profits and £445.80 where £30,000 and £1,045.80 are right.Simulation.calculatenow forgets such records for the variables the supplied-input helpers read (SUPPLIED_INPUT_VARIABLES) before the engine can refill the year, and the helpers raise for any variable outside that set. The new tests cover the default simulation and a branch, and a deleted loss both different from and equal to the carried one (9ca5d6eb).🤖 Generated with Claude Code