Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1916.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Charged Class 4 NICs on the profits chargeable under ITTOIA 2005 Part 2 Chapter 2, after capital allowances and the trading allowance, less trading losses under SSCBA 1992 Sch. 2 para. 3 (new `ni_class_4_profits`). Losses reduce only trade profits for Class 4 and carry forward until used.
3 changes: 3 additions & 0 deletions changelog.d/class-4-chapter-2-profits.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- Counted a trading loss for Class 4 NICs only in the year it is entered for, so a one-off `trading_loss` is relieved once and only its unrelieved part reduces later years' profits (new `ni_class_4_trading_loss`).
- Isolated supplied-input provenance between simulation clones and removed it for deleted inputs, preventing Class 4 from relieving cached carry-over losses again.
- Corrected the Class 4 property-test reference to use each year's trading allowance parameter, including the zero allowance in historical years.
4 changes: 2 additions & 2 deletions docs/book/programs/gov/hmrc/national-insurance.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"- **Class 1**: Paid by employees and their employers. This is based on the employee's earnings and is deducted directly from wages.\n",
"- **Class 2**: Paid by self-employed people with profits above a certain threshold.\n",
"- **Class 3**: Voluntary contributions, often made by people who want to fill gaps in their National Insurance record.\n",
"- **Class 4**: Paid by self-employed people with profits above a certain threshold, in addition to Class 2 NICs."
"- **Class 4**: Paid by self-employed people with profits above a certain threshold (in addition to Class 2 NICs before April 2024)."
]
},
{
Expand Down Expand Up @@ -171,7 +171,7 @@
{
"cell_type": "markdown",
"metadata": {},
"source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL). These primary contributions are due from age 16 until state pension age (`ni_liable`): SSCBA 1992 s.6(3) ends them at pensionable age.\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable. These secondary contributions are due on earners over 16 with no upper age limit (`ni_class_1_secondary_liable`), so employers keep paying them for employees over state pension age (SSCBA 1992 s.6(1)(b) and s.6(3)).\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`."
"source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL). These primary contributions are due from age 16 until state pension age (`ni_liable`): SSCBA 1992 s.6(3) ends them at pensionable age.\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable. These secondary contributions are due on earners over 16 with no upper age limit (`ni_class_1_secondary_liable`), so employers keep paying them for employees over state pension age (SSCBA 1992 s.6(1)(b) and s.6(3)).\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL, charged on `ni_class_4_profits`. Those are the trade profits chargeable to income tax under ITTOIA 2005 Part 2 Chapter 2 (`ni_class_4_profits_before_losses`: self-employment profit after capital allowances and the trading allowance), less trading losses as SSCBA 1992 Schedule 2 paragraph 3 allows (`ni_class_4_loss_relief`). A loss reduces only trade profits for Class 4: each year's losses (those brought forward, then the year's own loss, `ni_class_4_trading_loss`) are set against that year's trade profits, and the rest is carried forward until used (`ni_class_4_losses_brought_forward`, `ni_class_4_losses_carried_forward`). A year's own loss is the `trading_loss` entered for that year, so a loss entered once is relieved once: it is not counted again in the later years that `trading_loss` carries into. Carry-back claims and the paragraph 3(5) deductions for trade interest and royalties are not modelled. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`."
},
{
"cell_type": "markdown",
Expand Down
36 changes: 35 additions & 1 deletion policyengine_uk/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# PolicyEngine core imports
from policyengine_core.data import Dataset
from policyengine_core.enums import Enum as CoreEnum
from policyengine_core.periods import period as period_
from policyengine_core.periods import Period, period as period_
from policyengine_core.parameters import Parameter
from policyengine_core.reforms import Reform
from policyengine_core.simulations import Simulation as CoreSimulation
Expand All @@ -30,6 +30,10 @@
)
from policyengine_uk.data.dataset_sources import materialize_gcs_dataset_url
from policyengine_uk.utils.dependencies import get_variable_dependencies
from policyengine_uk.utils.supplied_inputs import (
SUPPLIED_INPUT_VARIABLES,
drop_missing_supplied_inputs,
)
from policyengine_uk.reforms import create_structural_reforms_from_parameters
from policyengine_uk.parameters.gov.simulation.labour_supply_responses.aliases import (
canonicalize_lsr_parameter_path,
Expand Down Expand Up @@ -219,6 +223,30 @@ def __init__(
if scenario.parameter_changes is not None:
self.apply_parameter_changes(scenario.parameter_changes)

def clone(
self,
debug: bool = False,
trace: bool = False,
clone_tax_benefit_system: bool = True,
) -> "Simulation":
clone = super().clone(debug, trace, clone_tax_benefit_system)
# policyengine-core 3.32.9: simulations/simulation.py::Simulation.clone
# shallow-copies __dict__, while holders/holder.py::Holder.clone copies
# value storage. Holder.set_input's provenance and context must belong
# to the same simulation as that storage, including for plain clones.
clone._user_input_keys = set(getattr(self, "_user_input_keys", ()))
clone._user_input_contexts = list(getattr(self, "_user_input_contexts", ()))
return clone

def delete_arrays(self, variable: str, period: Period = None) -> None:
super().delete_arrays(variable, period)
# policyengine-core's Simulation.delete_arrays and
# holders/holder.py::Holder.delete_arrays remove storage, but retain
# provenance keys. Drop those keys before carry-over can refill storage.
# Inspect storage after core's deletion to honour period containment
# and the current branch, ancestor branches and default branch.
drop_missing_supplied_inputs(self, variable)

def reset_calculations(self):
for variable in self.tax_benefit_system.variables:
if variable not in self.input_variables:
Expand Down Expand Up @@ -616,6 +644,12 @@ def calculate(

period = period_(period)

if variable_name in SUPPLIED_INPUT_VARIABLES:
# A value deleted straight from the holder leaves its input record
# behind. Forget it before the engine can refill the period with a
# carried-over value that the record would pass off as supplied.
drop_missing_supplied_inputs(self, variable_name)

return super().calculate(
variable_name, period, map_to=map_to, decode_enums=decode_enums
)
Expand Down
13 changes: 13 additions & 0 deletions policyengine_uk/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@
os.environ["POLICYENGINE_UK_DEFAULT_DATASET"] = DEFAULT_TEST_DATASET_URL


@pytest.fixture(scope="module")
def cloned_uk_tax_benefit_system():
"""Load model code once, with independent model state per simulation."""
import policyengine_uk.simulation as uk_simulation

tax_benefit_system = uk_simulation.CountryTaxBenefitSystem()
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(
uk_simulation, "CountryTaxBenefitSystem", tax_benefit_system.clone
)
yield


def pytest_collection_modifyitems(config, items):
has_default_dataset = bool(os.environ.get("POLICYENGINE_UK_DEFAULT_DATASET"))
if has_default_dataset:
Expand Down
Loading
Loading