diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index e3b947e134..1369d958ea 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -19,12 +19,6 @@ # Keep entries narrow. An entry that matches nothing in a run is reported as a # warning; remove it once its bug is fixed. Failures can never be accepted. -[[allow]] -nodeid = "policyengine_uk/tests/test_uc_state_pension_properties.py::test_state_pension_does_not_change_partners_marriage_allowance" -outcome = "xfailed" -reason = "#1947" -why = "Known bug #1947 (Marriage Allowance booked on the recipient), pinned by a strict xfail until it is fixed." - [[allow]] nodeid = "policyengine_uk/tests/test_build_metadata.py::test_runtime_metadata_uses_bundle_contract_when_available" outcome = "skipped" diff --git a/changelog.d/1947.fixed.md b/changelog.d/1947.fixed.md new file mode 100644 index 0000000000..59d59411f5 --- /dev/null +++ b/changelog.d/1947.fixed.md @@ -0,0 +1 @@ +- Model Marriage Allowance as ITA 2007 ss. 55A-55E provide: the electing spouse gives up the fixed transferable amount from their own personal allowance and the other spouse gets a Step 6 tax reduction of the basic (or Scottish basic) rate times that amount, instead of the recipient's taxable income falling by the transferor's unused allowance. diff --git a/changelog.d/1963.fixed.md b/changelog.d/1963.fixed.md new file mode 100644 index 0000000000..a45391364c --- /dev/null +++ b/changelog.d/1963.fixed.md @@ -0,0 +1 @@ +- Made counted-once guard failures show only validated public diagnostics. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml deleted file mode 100644 index 526252c4ed..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: Tax bands whose members may transfer personal allowance under the Marriage Allowance election (ITA 2007 s.55B). A transferor is eligible if they are not liable at the higher rate or above; the Scottish intermediate rate is treated as within the basic-rate envelope for this purpose. -values: - 0000-01-01: - - NONE - - STARTER - - BASIC - - INTERMEDIATE -metadata: - unit: list - label: Marriage Allowance eligible tax bands - period: year - reference: - - title: "Income Tax Act 2007, s. 55B" - href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/fixed_amount.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/fixed_amount.yaml new file mode 100644 index 0000000000..a443717068 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/fixed_amount.yaml @@ -0,0 +1,10 @@ +description: Transferable amount where the Act fixes it in pounds rather than as a share of the personal allowance. For 2015-16 it is £1,060 (ITA 2007 s. 55B(4)(a)); from 2016-17 the amount is the share of the personal allowance in `max`, so this is zero. +values: + 2015-04-06: 1_060 + 2016-04-06: 0 +metadata: + unit: currency-GBP + label: Marriage Allowance fixed transferable amount + reference: + - title: Income Tax Act 2007 s. 55B(4)(a) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml index bbde24b1b1..21e32ebf9f 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml @@ -1,9 +1,9 @@ -description: Maximum Marriage Allowance taxable income reduction, as a percentage of the full Personal Allowance. +description: Marriage Allowance transferable amount, as a share of the personal allowance in ITA 2007 s. 35(1). For 2015-16 the Act fixes the amount at £1,060, which is 10% of that year's £10,600 allowance. values: - 2016-04-01: 0.1 + 2015-04-06: 0.1 metadata: unit: /1 - label: Marriage Allowance maximum percentage + label: Marriage Allowance transferable share of the personal allowance reference: - - title: Income Tax Act 2007 s. 55B(4)(b) - href: https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A + - title: Income Tax Act 2007 s. 55B(4) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml index af43fa56bd..c396ce4e47 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml @@ -1,4 +1,4 @@ -description: The Marriage Allowance is rounded up by this increment. +description: The Marriage Allowance transferable amount is rounded up to a multiple of this amount. values: 2016-04-01: 10 metadata: @@ -6,4 +6,4 @@ metadata: label: Marriage Allowance rounding increment reference: - title: Income Tax Act 2007 s. 55B(5) - href: https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A + href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml index d40254f49b..f4e83a3d2c 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml @@ -5,7 +5,6 @@ values: - taxable_dividend_income - taxable_property_income - received_allowances_earned_income - - marriage_allowance metadata: unit: list diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml index e6bc103f75..45f66d9ad8 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml @@ -12,3 +12,4 @@ values: - capped_mcad - other_tax_credits - property_finance_cost_relief + - marriage_allowance_tax_reduction diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 16ddaa7960..7f5b324472 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -1,12 +1,16 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.income_tax import net_income_from_adjusted_net_income from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.variables.gov.hmrc.income_tax.allowances.meets_marriage_allowance_income_conditions import ( + liable_only_at_marriage_allowance_rates, +) from typing import Union, Optional def create_expanded_ma_reform( max_child_age: Optional[int] = None, child_education_levels: Optional[List[str]] = None, + remove_income_condition: bool = True, ) -> Reform: class meets_expanded_ma_conditions(Variable): label = "Qualifies for an expanded Marriage Allowance" @@ -34,70 +38,60 @@ def formula(person, period): class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" - documentation = "Whether this person (and their partner) meets the conditions for this person to be eligible for the Marriage Allowance, as set out in the Income Tax Act 2007 sections 55B and 55C" + documentation = ( + "Whether this person pays income tax only at the rates that allow " + "a Marriage Allowance election, or meets the reform's expansion " + "conditions, which lift that restriction." + ) entity = Person definition_period = YEAR value_type = bool reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" def formula(person, period, parameters): - band = person("tax_band", period) - eligible_bands = parameters( - period - ).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands - base_eligible = np.isin(band.decode_to_str(), eligible_bands) - # Expand to higher bands if the reform's expansion conditions are met. - bands = band.possible_values - expansion = person("meets_expanded_ma_conditions", period) & ( - (band == bands.HIGHER) | (band == bands.ADDITIONAL) + conditions = liable_only_at_marriage_allowance_rates( + person, period, parameters ) - return base_eligible | expansion + if not remove_income_condition: + return conditions + return conditions | person("meets_expanded_ma_conditions", period) - class marriage_allowance(Variable): + class marriage_allowance_transferable_amount(Variable): value_type = float entity = Person - label = "Marriage Allowance" + label = "Marriage Allowance transferable amount" + documentation = ( + "The share of the personal allowance a Marriage Allowance election " + "transfers, at the reform's rate for couples meeting its expansion " + "conditions, rounded up to the rounding increment." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A" + reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" unit = GBP def formula(person, period, parameters): - marital = person("marital_status", period) - married = marital == marital.possible_values.MARRIED - eligible = ( - married - & person("is_claimant_or_partner", period) - & person("meets_marriage_allowance_income_conditions", period) - ) - transferable_amount = person("partners_unused_personal_allowance", period) - allowances = parameters(period).gov.hmrc.income_tax.allowances - capped_percentage = allowances.marriage_allowance.max - expanded_ma_cap = parameters( - period - ).gov.contrib.cps.marriage_tax_reforms.expanded_ma.ma_rate - capped_percentage = where( + p = parameters(period) + allowances = p.gov.hmrc.income_tax.allowances + share = where( person("meets_expanded_ma_conditions", period), - expanded_ma_cap, - capped_percentage, - ) - max_amount = allowances.personal_allowance.amount * capped_percentage - amount_if_eligible_pre_rounding = max_( - min_(transferable_amount, max_amount), 0 - ) - # Round up. - rounding_increment = allowances.marriage_allowance.rounding_increment - amount_if_eligible = ( - np.ceil(amount_if_eligible_pre_rounding / rounding_increment) - * rounding_increment + p.gov.contrib.cps.marriage_tax_reforms.expanded_ma.ma_rate, + allowances.marriage_allowance.max, ) - takes_up = person("would_claim_marriage_allowance", period) - return eligible * amount_if_eligible * takes_up + amount = allowances.personal_allowance.amount * share + increment = allowances.marriage_allowance.rounding_increment + rounded = np.ceil(amount / increment) * increment + # 2015-16 is a fixed amount (ITA 2007 s. 55B(4)(a)) unless the + # reform's expansion applies. + fixed = allowances.marriage_allowance.fixed_amount + if fixed == 0: + return rounded + return where(person("meets_expanded_ma_conditions", period), rounded, fixed) class reform(Reform): def apply(self): self.add_variable(meets_expanded_ma_conditions) self.update_variable(meets_marriage_allowance_income_conditions) - self.update_variable(marriage_allowance) + self.update_variable(marriage_allowance_transferable_amount) return reform @@ -232,6 +226,7 @@ def create_marriage_tax_reform(parameters, period): if remove_income_condition or rate != default_rate: ma_reform = create_expanded_ma_reform( max_child_age=ma_max_child_age if ma_max_child_age > 0 else None, + remove_income_condition=bool(remove_income_condition), ) else: ma_reform = None @@ -258,4 +253,5 @@ def create_marriage_tax_reform(parameters, period): # These build the reform classes with no child-age / education conditions, so the reform # applies to every married couple regardless of child presence. expanded_ma_reform = create_expanded_ma_reform() +expanded_ma_reform_rate_only = create_expanded_ma_reform(remove_income_condition=False) marriage_neutral_it_reform = create_marriage_neutral_income_tax_reform() diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py index 1c735e180e..a7d432b8b8 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py @@ -9,7 +9,10 @@ import numpy as np import pytest -from policyengine_uk.utils.aggregate_impact.measure import measure +from policyengine_uk.utils.aggregate_impact.measure import ( + component_guard_diagnostic, + measure, +) from policyengine_uk.utils.aggregate_impact.partition import ( default_partition_path, load_partition, @@ -86,7 +89,7 @@ def test_every_tax_component_is_counted_exactly_once(measured): rows = {r["variable"]: r for r in guard["components"]} for head in partition.tax_heads: row = rows[head.variable] - assert row["status"] == "pass", row + assert row["status"] == "pass", component_guard_diagnostic(row, partition) for total, k in row["coefficients"].items(): assert k == pytest.approx(1, abs=1e-3), (head.id, total) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py index eca0398709..bc644800f1 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py @@ -5,6 +5,7 @@ """ from datetime import date +import types import numpy as np import pytest from policyengine_core.periods import period @@ -442,20 +443,23 @@ def test_partial_setter_failure_restores_only_touched_input(monkeypatch): parent, child, sibling, part, saved, keys, model = _subset_origin_fixture() before = values(child) snapshot = guard.snapshot_inputs(child, part.component_variables()) - original = child.set_input + original = type(child).set_input failed = False class DeliberatePartialSetterFailure(Exception): pass - def partial(name, when, value): + def partial(simulation, name, when, value): nonlocal failed - original(name, when, value) - if name == U and not failed: + original(simulation, name, when, value) + if simulation is child and name == U and not failed: failed = True raise DeliberatePartialSetterFailure() - monkeypatch.setattr(child, "set_input", partial) + # Bound to the child, so a branch made from it while the guard calculates + # (the Marriage Allowance election makes two) gets the setter bound to the + # branch, and its inputs stay in the branch. + monkeypatch.setattr(child, "set_input", types.MethodType(partial, child)) with pytest.raises(DeliberatePartialSetterFailure): guard.counted_once(child, part, YEAR, 1e-6, snapshot, variables_to_check=[U]) assert failed and genuine(child, U) is None diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py index 20636f0dad..a711676fee 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py @@ -1096,3 +1096,158 @@ def guard(units): assert shown in text and "NO LONGER contained" in text if units == 3: assert "0.43" not in text + + +def test_certified_guard_failure_message_excludes_private_fields(): + """A failed invariant stays failed, with no raw survey detail in its message.""" + from policyengine_uk.tests.aggregate_impact import test_aggregate_impact_certified + from policyengine_uk.utils.aggregate_impact.partition import ( + default_partition_path, + load_partition, + ) + + partition = load_partition(default_partition_path()) + row = { + "variable": partition.tax_heads[0].variable, + "status": "fail", + "problems": [ + { + "total": "gov_tax", + "head": "income_tax", + "kind": "own_head", + "households": 3, + "where": np.array([True, False]), + "share": 0.123456, + } + ], + "detail": "SECRET_C4_household_amount", + "household_id": "SECRET_C4_household_id", + "responses": {"SECRET_C4_response": 1_234.5}, + } + measured = ( + { + "guard": { + "status": "ran", + "reset_reproduces_baseline": True, + "components": [row], + } + }, + None, + partition, + None, + ) + with pytest.raises(AssertionError) as error: + test_aggregate_impact_certified.test_every_tax_component_is_counted_exactly_once( + measured + ) + message = str(error.value) + assert "SECRET_C4" not in message + assert "income_tax" in message and "own_head" in message + assert "where" not in message and "share" not in message + + +@pytest.mark.parametrize( + "slot", ["variable", "status", "total", "head", "kind", "households"] +) +def test_guard_component_diagnostic_rejects_untrusted_slot_values(slot): + """An allowed key cannot smuggle an object or an arbitrary private string.""" + import json + + from policyengine_uk.utils.aggregate_impact.measure import ( + component_guard_diagnostic, + ) + from policyengine_uk.utils.aggregate_impact.partition import ( + default_partition_path, + load_partition, + ) + + class Unprintable: + def __str__(self): + raise AssertionError("SECRET_C4_str_was_called") + + __repr__ = __str__ + __hash__ = __str__ + + def __eq__(self, other): + raise AssertionError("SECRET_C4_eq_was_called") + + def __int__(self): + raise AssertionError("SECRET_C4_int_was_called") + + class UntrustedString(str): + def __str__(self): + raise AssertionError("SECRET_C4_subclass_was_printed") + + partition = load_partition(default_partition_path()) + for value in ("SECRET_C4", Unprintable(), UntrustedString("income_tax")): + problem = { + "total": "gov_tax", + "head": "income_tax", + "kind": "own_head", + "households": 10, + } + row = {"variable": "income_tax", "status": "fail", "problems": [problem]} + if slot in ("variable", "status"): + row[slot] = value + else: + problem[slot] = value + diagnostic = component_guard_diagnostic(row, partition) + rendered = json.dumps(diagnostic) + assert "SECRET_C4" not in rendered + field = ( + diagnostic[slot] + if slot in ("variable", "status") + else diagnostic["problems"][0][slot] + ) + assert field == (None if slot == "households" else "unknown") + + +@pytest.mark.parametrize( + "count,shown", + [ + (0, 0), + (3, None), + (9, None), + (10, 10), + (100, 100), + (None, None), + (True, None), + (3.5, None), + ], +) +def test_guard_component_diagnostic_preserves_suppressed_counts(count, shown): + from policyengine_uk.utils.aggregate_impact.measure import ( + component_guard_diagnostic, + ) + from policyengine_uk.utils.aggregate_impact.partition import ( + default_partition_path, + load_partition, + ) + + partition = load_partition(default_partition_path()) + row = { + "variable": "income_tax", + "status": "fail", + "units": 100, + "problems": [ + { + "total": "gov_tax", + "head": "income_tax", + "kind": "own_head", + "households": count, + } + ], + } + diagnostic = component_guard_diagnostic(row, partition) + assert diagnostic == { + "variable": "income_tax", + "status": "fail", + "problems": [ + { + "total": "gov_tax", + "head": "income_tax", + "kind": "own_head", + "households": shown, + } + ], + } diff --git a/policyengine_uk/tests/code_health/test_ci_test_shards.py b/policyengine_uk/tests/code_health/test_ci_test_shards.py index 0193a5b67f..af13fdff6f 100644 --- a/policyengine_uk/tests/code_health/test_ci_test_shards.py +++ b/policyengine_uk/tests/code_health/test_ci_test_shards.py @@ -965,3 +965,39 @@ def test_the_release_tells_policyengine_py_only_after_pypi_has_it(): assert "repos/PolicyEngine/policyengine.py/dispatches" in run assert "event_type=policyengine-uk-release" in run assert "client_payload[version]=$VERSION" in run + + +@pytest.mark.parametrize("dist_artifact", ["", "release-dist"]) +def test_configured_source_and_wheel_shards_account_for_every_node( + conftest, dist_artifact +): + """The two configured runner budgets retain the same strict coverage audit.""" + import yaml + + workflow = yaml.safe_load( + (GITHUB_DIR / "workflows" / "test-suite.yaml").read_text() + ) + matrix = workflow["jobs"]["Python"]["strategy"]["matrix"]["shard"] + + # The shared matrix is the runner set for both source and wheel; N is + # derived from the actual configured jobs, never a second fixed count. + selected = matrix + assert isinstance(selected, list) and selected + assert selected == list(range(1, len(selected) + 1)) + collected = [f"tests/public_synthetic.py::test_case[{i}]" for i in range(200)] + shards = [ + [ + checker.Record(nodeid, "passed") + for nodeid in collected + if conftest.shard_of(nodeid, len(selected)) == number + ] + for number in selected + ] + mode = "wheel" if dist_artifact else "source" + assert checker.check(collected, shards, mode=mode, data="present").problems == [] + populated = next(cases for cases in shards if cases) + missing = [cases for cases in shards if cases is not populated] + assert checker.check(collected, missing, mode=mode, data="present").problems + assert checker.check( + collected, [*shards, populated], mode=mode, data="present" + ).problems diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index dd01419059..9e9ebbf5d0 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -122,8 +122,8 @@ relation_type: COUPLE # Pensioner: (16,000 - 12,570) x 20% = 686, on State Pension. # Partner: (13,000 - 12,570) x 20% = 86 and (13,000 - 12,569.96) x 8% - # = 34.40. Both are above the personal allowance, so there is no - # Marriage Allowance. + # = 34.40. Either spouse electing for Marriage Allowance would cost them + # 252 and save the other 252, so the couple does not elect. income_tax: [686, 86] uc_income_tax_on_earnings: [0, 86] uc_national_insurance_on_earnings: [0, 34.40] @@ -204,11 +204,10 @@ household: members: [recipient, transferor] output: - # The transferor's unused allowance of 2,570 exceeds the 1,257 cap, which - # rounds up to 1,260. Tax on the recipient's earnings: - # (15,000 - 12,570 - 1,260) x 20% = 234, the same as 486 less the - # 20% x 1,260 = 252 reduction. Dividends: (5,000 - 500) x 10.75% = 483.75, - # not deducted. + # The transferor (10,000, below the cut allowance of 11,310) elects, and + # the recipient's tax falls by 20% x 1,260 = 252. Tax on the recipient's + # earnings: (15,000 - 12,570) x 20% = 486, less the 252 reduction = 234. + # Dividends: (5,000 - 500) x 10.75% = 483.75, not deducted. marriage_allowance: [1_260, 0] income_tax: [717.75, 0] uc_income_tax_on_earnings: [234, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index f3f11f8954..7851fd427c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -337,12 +337,23 @@ members: [claimant, partner] country: SCOTLAND output: - # The 1,040 of earnings add 210.89 of income tax (they cross into the - # Scottish intermediate band), so net earnings are 829.11 and the 20 pound - # disregard of 1,040 is capped there; income is as with no earnings (next). - income_tax: [3_016.62, 0] - pension_credit_earnings_disregard: 829.11 - pension_credit_income: 25_194.27 + # FY 2025-26: Scottish resolution S6M-16531 and HMRC's personal allowance. + # Tax before Marriage Allowance is 2,827 * 19% + 12,094 * 20% + + # 1,549 * 21% = 3,281.22. The s.55B credit is 1,260 * 20% = 252. + # Earnings add 218.40 of tax, leaving 821.60 net earnings; the 20 pound + # weekly disregard (SPC Regs Sch VI) is capped there. Countable income + # is 29,040 - 3,029.22 - 821.60 = 25,189.18, as without earnings below. + income_tax: [3_029.22, 0] + pension_credit_employment_income_tax: 218.40 + pension_credit_earnings_disregard: 821.60 + pension_credit_income: 25_189.18 + # Both partners receive AA, with no other resident or carer. The 2025-26 + # DWP rates give a couple minimum of (346.60 + 165.80) * 52 = 26,644.80. + severe_disability_minimum_guarantee_addition: 165.80 * 52 + minimum_guarantee: (346.60 + 165.80) * 52 + is_pension_credit_eligible: true + guarantee_credit: 1_455.62 + pension_credit: 1_455.62 - name: The same Scottish couple with no earnings has the same Pension Credit income. period: 2025 @@ -365,9 +376,18 @@ members: [claimant, partner] country: SCOTLAND output: - income_tax: [2_805.73, 0] + # The same Scottish bands give 3,062.82 before the 252 Marriage + # Allowance credit. Income is 28,000 - 2,810.82 = 25,189.18, leaving + # 1,455.62 below the same 26,644.80 minimum guarantee. + income_tax: [2_810.82, 0] + pension_credit_employment_income_tax: 0 pension_credit_earnings_disregard: 0 - pension_credit_income: 25_194.27 + pension_credit_income: 25_189.18 + severe_disability_minimum_guarantee_addition: 165.80 * 52 + minimum_guarantee: (346.60 + 165.80) * 52 + is_pension_credit_eligible: true + guarantee_credit: 1_455.62 + pension_credit: 1_455.62 - name: Caring hours without entitlement to carer's allowance do not qualify for the 20 pound disregard (Sch VI para 3 needs Sch I para 4). period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index 673614ccf2..5ce3a79dc1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -63,14 +63,560 @@ marriage_allowance: [0, 0, 0, 0] income_tax: [9_432, 4_486, 0, 0] +# ITA 2007 ss. 55A-55E. 2026-27: personal allowance 12,570 (s. 35(1)), so the +# transferable amount is 10% x 12,570 = 1,257, rounded up to 1,260 +# (s. 55B(4)(b), (5)). The transferor's allowance falls to 11,310 (s. 55B(6)); +# the recipient's tax falls by 20% x 1,260 = 252 at Step 6 (s. 55A(2), +# s. 55B(1), (3)), capped at their Step 5 tax (s. 29(2)). The couple elects +# only when that lowers their combined tax. + +- name: Issue example - transferor between the cut and the full allowance + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Transferor: (12,000 - 11,310) x 20% = 138. Recipient: + # (30,000 - 12,570) x 20% - 252 = 3,486 - 252 = 3,234. The couple pays + # 3,372 against 3,486 without the election. + makes_marriage_allowance_election: [false, true] + marriage_allowance_relinquished: [0, 1_260] + marriage_allowance: [1_260, 0] + personal_allowance: [12_570, 11_310] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [3_234, 138] + +- name: Transferor with no income + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + marriage_allowance: [1_260, 0] + income_tax: [3_234, 0] + +- name: Transferor just under the allowance still saves the couple tax + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_500 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Transferor: (12,500 - 11,310) x 20% = 238, less than the 252 saved. + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 238] + +- name: Transferor using the whole allowance gains nothing, so the couple does not elect + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_570 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Electing would cost the transferor 1,260 x 20% = 252, exactly the + # recipient's reduction. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + personal_allowance: [12_570, 12_570] + income_tax: [3_486, 0] + +- name: Issue departure 1 - transferor with 15,000 of interest stays at the starting rate for savings + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + savings_interest_income: 15_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Transferor: 15,000 - 11,310 = 3,690 of interest, all inside the 5,000 + # starting rate for savings at 0% (s. 12), so they can elect + # (s. 55C(1)(c)) and pay nothing more. Recipient: 3,486 - 252 = 3,234. + makes_marriage_allowance_election: [false, true] + personal_allowance: [12_570, 11_310] + income_tax: [3_234, 0] + +- name: Issue departure 1 - transferor with 12,000 of interest + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + savings_interest_income: 12_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 0] + +- name: Issue departure 2 - recipient taxed mostly on dividends keeps the reduction + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 12_570 + dividend_income: 5_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Earnings use the whole allowance. Dividends: 500 at the dividend nil + # rate, 4,500 x 10.75% = 483.75 at Step 5, less 252 = 231.75. + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [231.75, 0] + +- name: Issue departure 3 - Scottish intermediate-rate recipient saves the Scottish basic rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 5_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: SCOTLAND + output: + # Recipient: 17,430 taxable = 3,967 x 19% + 12,989 x 20% + 474 x 21% + # = 753.73 + 2,597.80 + 99.54 = 3,451.07, less 20% x 1,260 = 252, not + # the 21% marginal rate. + marriage_allowance_appropriate_percentage: [0.2, 0.2] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [3_199.07, 0] + +- name: Scottish starter-rate transferor + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: SCOTLAND + output: + # Transferor: 690 x 19% = 131.10, less than the 252 saved. + makes_marriage_allowance_election: [false, true] + income_tax: [3_199.07, 131.10] + +- name: Issue departure 5 - both partners below the allowance, no election and no double counting + period: 2026 + input: + people: + p1: + age: 40 + employment_income: 5_000 + p2: + age: 38 + employment_income: 6_000 + benunits: + benunit: + members: [p1, p2] + is_married: true + output: + # Neither has tax to reduce. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [0, 0] + +- name: Both partners basic-rate taxpayers, no election + period: 2026 + input: + people: + p1: + age: 40 + employment_income: 30_000 + p2: + age: 38 + employment_income: 20_000 + benunits: + benunit: + members: [p1, p2] + is_married: true + output: + makes_marriage_allowance_election: [false, false] + income_tax: [3_486, 1_486] + +- name: Higher-rate recipient cannot gain (s. 55B(2)(b)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 60_000 + transferor: + age: 38 + employment_income: 5_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # 37,700 x 20% + 9,730 x 40% = 11,432. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [11_432, 0] + +- name: Issue departure 6 - no election when married couple's allowance is claimed (s. 55B(2)(d)) + period: 2026 + input: + people: + # Married couple's allowance needs a spouse born before 6 April 1935. + recipient: + age: 92 + employment_income: 30_000 + married_couples_allowance: 11_270 + transferor: + age: 90 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # 3,486 less 10% x 11,270 = 1,127. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [2_359, 0] + +- name: The couple's take-up follows the spouse who would gain + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + would_claim_marriage_allowance: false + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, false] + income_tax: [3_486, 0] + +- name: The electing spouse's take-up value does not stop the election + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 0] + +- name: The lower earner elects whichever spouse is older + period: 2026 + input: + people: + transferor: + age: 40 + employment_income: 8_000 + recipient: + age: 38 + employment_income: 30_000 + benunits: + benunit: + members: [transferor, recipient] + is_married: true + output: + makes_marriage_allowance_election: [true, false] + marriage_allowance: [0, 1_260] + personal_allowance: [11_310, 12_570] + income_tax: [0, 3_234] + +- name: Reduction capped at the recipient's Step 5 tax (s. 29(2)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 13_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Step 5 tax (13,000 - 12,570) x 20% = 86, so the reduction is 86. + makes_marriage_allowance_election: [false, true] + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [86, 0] + income_tax: [0, 0] + +- name: Other tax reductions leave less tax for the reduction (s. 27(2), s. 29(2)-(3)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 15_000 + other_tax_credits: 300 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Step 5 tax 2,430 x 20% = 486, less 300 leaves 186 for the reduction. + marriage_allowance_tax_reduction_limit: [186, 0] + marriage_allowance_tax_reduction: [186, 0] + income_tax: [0, 0] + +- name: When either spouse's election saves the same, the elder elects + period: 2026 + input: + people: + p1: + age: 40 + savings_interest_income: 15_000 + dividend_income: 10_000 + p2: + age: 38 + savings_interest_income: 15_000 + dividend_income: 10_000 + benunits: + benunit: + members: [p1, p2] + is_married: true + output: + # The spouses are identical, so either election saves the same and the + # elder elects. Income tax is not asserted: the model sets allowances + # against savings before dividends, where s. 25(2) requires the order + # that most reduces tax. + makes_marriage_allowance_election: [true, false] + personal_allowance: [11_310, 12_570] + +- name: Children in the benefit unit are not spouses + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + child: + age: 5 + benunits: + benunit: + members: [recipient, transferor, child] + is_married: true + output: + is_marriage_allowance_spouse: [true, true, false] + makes_marriage_allowance_election: [false, true, false] + marriage_allowance: [1_260, 0, 0] + income_tax: [3_234, 0, 0] + +- name: s. 55B(2)(ba) - dividends in the nil rate above the basic rate limit bar the recipient + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 50_070 + dividend_income: 500 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Taxable 37,500 + 500: without the dividend nil rate 300 of dividends + # would be at the dividend upper rate. + makes_marriage_allowance_election: [false, false] + income_tax: [7_500, 0] + +- name: Savings in the savings nil rate above the basic rate limit do not bar the recipient + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 50_070 + savings_interest_income: 500 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # The interest is at the savings nil rate, which s. 55B(2)(b) allows: + # 37,500 x 20% - 252 = 7,248. + makes_marriage_allowance_election: [false, true] + income_tax: [7_248, 0] + +- name: Scottish intermediate-rate recipient with savings banded on UK rates can gain + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 40_000 + savings_interest_income: 5_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: SCOTLAND + output: + makes_marriage_allowance_election: [false, true] + marriage_allowance_tax_reduction: [252, 0] + +- name: Pensioner transferor with State Pension, as in the UC State Pension test + period: 2026 + input: + # A couple 25 years apart is read as a parent and adult child unless the + # roles are supplied (is_claimant_or_partner). + people: + pensioner: + age: 70 + state_pension: 12_000 + is_claimant_or_partner: true + partner: + age: 45 + employment_income: 20_000 + is_claimant_or_partner: true + benunits: + benunit: + members: [pensioner, partner] + is_married: true + output: + # Pensioner: 690 x 20% = 138. Partner: 7,430 x 20% - 252 = 1,234. + makes_marriage_allowance_election: [true, false] + personal_allowance: [11_310, 12_570] + income_tax: [138, 1_234] + +- name: 2015-16 transferable amount is 1,060 (s. 55B(4)(a)) + period: 2015 + input: + people: + recipient: + age: 40 + employment_income: 20_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # (20,000 - 10,600) x 20% = 1,880, less 20% x 1,060 = 212. + marriage_allowance_transferable_amount: [1_060, 1_060] + personal_allowance: [10_600, 9_540] + income_tax: [1_668, 0] + +- name: 2019-20 transferable amount is 10% of 12,500 + period: 2019 + input: + people: + recipient: + age: 40 + employment_income: 20_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # 7,500 x 20% = 1,500, less 20% x 1,250 = 250. + marriage_allowance_transferable_amount: [1_250, 1_250] + income_tax: [1_250, 0] # Marriage Allowance is an election between spouses or civil partners (ITA 2007 # s.55B, s.55C(1)(a)): the claimant and partner of a married benefit unit, never # a child in it, whatever the child's age. - name: An 18-year-old dependant's unused allowance does not transfer to a parent - # p1 is a basic-rate taxpayer with no unused allowance of their own spouse - # (p2 earns 60,000), so p1 gets no Marriage Allowance. Previously the - # 18-year-old's unused 12,570 counted as a "partner's" allowance and p1 got - # 1,260 (a 252 tax cut). + # p2 earns 60,000, so p2 cannot gain and cannot elect (higher rate even with + # the allowance cut, s. 55C(1)(c)); p1 pays basic rate, so electing would + # cost p1 as much as it could save. The 18-year-old is not a spouse. + # Before #1896 the 18-year-old's unused allowance counted as a "partner's" + # and p1 got 1,260 (a 252 tax cut). period: 2025 input: people: @@ -89,7 +635,7 @@ members: [p1, p2, student] is_married: true output: - partners_unused_personal_allowance: [0, 0, 0] + is_marriage_allowance_spouse: [true, true, false] marriage_allowance: [0, 0, 0] marital_status: [MARRIED, MARRIED, SINGLE] @@ -134,3 +680,233 @@ members: [parent_1, parent_2, student] output: marriage_allowance: [0, 0, 0] + +- name: A landlord whose finance-cost reduction clears their tax gains nothing, so the couple does not elect + # ITA 2007 s. 29(2)-(3): a reduction is deducted only from Step 5 tax left + # after the reductions before it. The finance-cost reduction (ITTOIA 2005 + # s. 274AA) is deducted first; s. 27(2) gives the same total either way. + period: 2026 + input: + people: + landlord: + age: 40 + property_rental_income: 20_000 + property_income: 20_000 + property_finance_costs: 15_000 + earner: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [landlord, earner] + is_married: true + output: + # Landlord: Step 5 tax (20,000 - 12,570) x 20% = 1,486. Costs relieved + # min(15,000, 20,000, 7,430) = 7,430, so the reduction is 1,486 and no tax + # is left for a Marriage Allowance reduction. The earner electing would + # pay (12,000 - 11,310) x 20% = 138 and save the landlord nothing. + uses_property_allowance: [false, false] + property_finance_cost_relief: [1_486, 0] + marriage_allowance_tax_reduction_limit: [0, 0] + makes_marriage_allowance_election: [false, false] + marriage_allowance_tax_reduction: [0, 0] + income_tax: [0, 0] + +- name: A landlord's Marriage Allowance reduction is limited to the tax the finance-cost reduction leaves + period: 2026 + input: + people: + landlord: + age: 40 + property_rental_income: 20_000 + property_income: 20_000 + property_finance_costs: 6_700 + earner: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [landlord, earner] + is_married: true + output: + # Landlord: Step 5 tax 1,486, finance-cost reduction 20% x 6,700 = 1,340, + # leaving 146. The earner electing pays (12,000 - 11,310) x 20% = 138 and + # saves the landlord 146, so the couple elects and pays 138 against 146. + property_finance_cost_relief: [1_340, 0] + marriage_allowance_tax_reduction_limit: [146, 138] + makes_marriage_allowance_election: [false, true] + marriage_allowance_tax_reduction: [146, 0] + income_tax: [0, 138] + +- name: Take-up follows the gaining spouse when the electing spouse is older + # The draw that decides the couple's take-up is the gaining spouse's, not + # the elder spouse's. + period: 2026 + input: + people: + recipient: + age: 38 + employment_income: 30_000 + would_claim_marriage_allowance: false + transferor: + age: 40 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, false] + income_tax: [3_486, 0] + +- name: An older electing spouse's take-up value does not stop the election + period: 2026 + input: + people: + recipient: + age: 38 + employment_income: 30_000 + transferor: + age: 40 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 0] + +- name: The election is weighed under the property allowance route the couple uses + # Each property allowance route is weighed on the couple's tax, with the + # election it would bring (ITTOIA 2005 s. 783BL); the election is then + # weighed under the route chosen. Under policyengine-core 3.33 the + # election's branches dropped the chosen route and picked their own, so + # the earner elected as if on full relief while taxed on actual expenses + # (CI, 11 Oct 2026). + period: 2027 + input: + people: + landlord: + age: 40 + employment_income: 20_000 + property_rental_income: 10_000 + property_income: 10_000 + property_finance_costs: 2_000 + earner: + age: 38 + employment_income: 12_000 + property_rental_income: 800 + property_income: 800 + property_finance_costs: 200 + benunits: + benunit: + members: [landlord, earner] + is_married: true + output: + # Landlord: 7,430 x 20% + 10,000 x 22% (property basic rate) - 22% x + # 2,000 = 3,246 on actual expenses, against 3,466 with the property + # allowance, so actual expenses. Basic rates only, so they can gain. + # Earner, full relief: taxable 12,000; electing costs + # (12,000 - 11,310) x 20% = 138 and saves the landlord 20% x 1,260 = + # 252, so the couple pays 2,994 + 138 = 3,132. On actual expenses: 230 + # of property at 22% = 50.60 less 22% x 200 = 6.60; electing would raise + # that by 263.40, more than the 252 it saves, so no election and the + # couple pays 3,246 + 6.60 = 3,252.60. The couple keeps full relief and + # elects. + uses_property_allowance: [false, true] + makes_marriage_allowance_election: [false, true] + property_finance_cost_relief: [440, 0] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [2_994, 138] + +- name: The property allowance route is chosen on the couple's tax, with the election it brings + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_000 + property_income: 800 + property_rental_income: 800 + property_finance_costs: 200 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: LONDON + output: + # Receipts of 800 are within the 1,000 property allowance, so full relief + # applies (ITTOIA 2005 ss. 783BE-783BF) unless the transferor elects out + # to deduct expenses and take the finance-cost reduction (s. 783BL). + # Full relief: taxable income 12,000; electing for Marriage Allowance + # costs (12,000 - 11,310) x 20% = 138 and saves the recipient 252, so the + # couple pays 3,234 + 138 = 3,372. Electing out: profit 800 taxable, + # (12,800 - 12,570) x 20% - 20% x 200 = 6, but now electing would cost + # 252, so no election and the couple pays 3,486 + 6 = 3,492. The route + # that is cheaper for the transferor alone costs the couple 120 more. + uses_property_allowance: [false, true] + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 138] + +- name: A recipient whose earnings after pension relief reach the higher rate cannot gain (s. 55B(2)(b)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 60_000 + employee_pension_contributions: 5_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: LONDON + output: + # 60,000 less 5,000 of relieved contributions (FA 2004 s. 188) and the + # 12,570 allowance leaves 42,430, 4,730 of it at the higher rate: + # 37,700 x 20% + 4,730 x 40% = 9,432. So no election. + meets_marriage_allowance_income_conditions: [false, true] + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [9_432, 0] + +- name: A Gift Aid donor's dividends within the extended basic rate limit let them gain (s. 55B(2)(ba), ITA 2007 s. 414(2)(b)) + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 12_570 + dividend_income: 40_000 + gift_aid: 3_200 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: LONDON + output: + # The 3,200 gift grosses up to 4,000 and extends the basic rate limit to + # 41,700, so all 40,000 of dividends (above earnings that use the whole + # allowance) are within it: (40,000 - 500 dividend allowance) x 10.75% = + # 4,246.25, less 20% x 1,260 = 252 from the transferor's election. + meets_marriage_allowance_income_conditions: [true, true] + makes_marriage_allowance_election: [false, true] + marriage_allowance: [1_260, 0] + income_tax: [3_994.25, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 064d9679fc..b39981a534 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -1,41 +1,229 @@ -- name: Basic rate band is eligible for Marriage Allowance - period: 2021 +# ITA 2007 s. 55B(2)(b), (ba) and s. 55C(1)(c), (ca): only the basic rates, +# the Scottish starter and intermediate rates, the dividend ordinary rate and +# the nil and starting rates for savings. 2026-27: personal allowance 12,570, +# basic rate limit 37,700, Scottish higher rate from 31,092 of taxable income. + +- name: Basic-rate earner meets the conditions + period: 2026 + input: + employment_income: 30_000 + output: + meets_marriage_allowance_income_conditions: true + +- name: No income meets the conditions + period: 2026 + input: + employment_income: 0 + output: + meets_marriage_allowance_income_conditions: true + +- name: Taxable income exactly at the basic rate limit meets the conditions + period: 2026 input: - tax_band: BASIC + # 50,270 - 12,570 = 37,700, all at the basic rate. + employment_income: 50_270 output: meets_marriage_allowance_income_conditions: true -- name: Higher rate band is ineligible for Marriage Allowance - period: 2021 +- name: One pound into the higher rate fails the conditions + period: 2026 input: - tax_band: HIGHER + employment_income: 50_271 output: meets_marriage_allowance_income_conditions: false -- name: Additional rate band is ineligible for Marriage Allowance - period: 2021 +- name: Additional-rate earner fails the conditions + period: 2026 input: - tax_band: ADDITIONAL + employment_income: 150_000 output: meets_marriage_allowance_income_conditions: false -- name: Below-PA earner (band NONE) is eligible for Marriage Allowance (regression for #395) - period: 2021 +- name: Transferor test uses the cut allowance (s. 55C(1)(c)) + period: 2026 input: - tax_band: NONE + # Allowance 12,570 - 1,260 = 11,310, so 50,000 leaves 38,690 taxable, + # 990 above the basic rate limit. + employment_income: 50_000 + marriage_allowance_relinquished: 1_260 + output: + personal_allowance: 11_310 + meets_marriage_allowance_income_conditions: false + +- name: The same income meets the conditions with the full allowance + period: 2026 + input: + # 50,000 - 12,570 = 37,430, within the basic rate band. + employment_income: 50_000 output: meets_marriage_allowance_income_conditions: true -- name: Scottish starter rate band is eligible for Marriage Allowance - period: 2021 +- name: Scottish intermediate-rate earner meets the conditions + period: 2026 input: - tax_band: STARTER + # 40,000 - 12,570 = 27,430, below the Scottish higher rate at 31,092. + employment_income: 40_000 + region: SCOTLAND output: meets_marriage_allowance_income_conditions: true -- name: Scottish intermediate rate band is eligible for Marriage Allowance - period: 2021 +- name: Scottish starter-rate earner meets the conditions + period: 2026 + input: + employment_income: 14_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: Scottish higher-rate earner fails the conditions + period: 2026 + input: + # 44,000 - 12,570 = 31,430, above 31,092. + employment_income: 44_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: false + +- name: Scottish savings are banded on UK rates, so an intermediate-rate earner with savings meets the conditions + period: 2026 + input: + # Non-savings 27,430 is below the Scottish higher rate; savings stack on + # it to 32,430, below the UK basic rate limit of 37,700. + employment_income: 40_000 + savings_interest_income: 5_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: Savings within the savings nil rate above the basic rate limit meet the conditions + period: 2026 + input: + # Non-savings 50,070 - 12,570 = 37,500. The 500 of interest is covered by + # the savings nil rate (s. 12A), which s. 55B(2)(b) allows, so none of it + # is charged at the savings higher rate. + employment_income: 50_070 + savings_interest_income: 500 + output: + meets_marriage_allowance_income_conditions: true + +- name: Dividends within the dividend nil rate above the basic rate limit fail the conditions + period: 2026 + input: + # The same 500 as dividends: s. 55B(2)(ba) ignores the dividend nil rate + # (s. 13A), and 37,500 + 500 = 38,000 would put 300 at the dividend upper + # rate. + employment_income: 50_070 + dividend_income: 500 + output: + meets_marriage_allowance_income_conditions: false + +- name: Savings charged at the savings higher rate fail the conditions + period: 2026 + input: + # Taxable income 37,500 + 2,000 = 39,500. A higher-rate taxpayer's + # savings nil rate covers 500, leaving savings at the higher rate. + employment_income: 50_070 + savings_interest_income: 2_000 + output: + meets_marriage_allowance_income_conditions: false + +- name: The dividend test uses the basic rate limit (2025-26, 37,700) + period: 2025 + input: + # Taxable income 49,970 - 12,570 = 37,400, plus 250 of dividends within + # the dividend allowance: 37,650 is inside the basic rate limit, so no + # dividends would be at the dividend upper rate even without s. 13A. + employment_income: 49_970 + dividend_income: 250 + output: + meets_marriage_allowance_income_conditions: true + +- name: Dividends above property income cross the basic rate limit and fail the conditions + period: 2026 + input: + # Property 47,000 less the 1,000 property allowance and the allowance of + # 12,570 leaves 33,430 of non-savings income. Dividends sit on top + # (s. 16), from 33,430 to 38,430, so 730 would be at the dividend upper + # rate (s. 55B(2)(ba)). + property_income: 47_000 + dividend_income: 5_000 + output: + meets_marriage_allowance_income_conditions: false + +- name: Dividends that reach the basic rate limit exactly meet the conditions (s. 55B(2)(ba)) + period: 2026 + input: + # 50,070 - 12,570 = 37,500 of earnings, then 200 of dividends to 37,700: + # even without the dividend nil rate (s. 13A) none would be at the + # dividend upper rate. Companion case B of TheAxiomFoundation/rulespec-uk#437. + employment_income: 50_070 + dividend_income: 200 + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound of dividends above the basic rate limit fails the conditions (s. 55B(2)(ba)) + period: 2026 + input: + # 37,500 + 201 = 37,701: without s. 13A, 1 would be at the dividend upper + # rate. Companion case C of TheAxiomFoundation/rulespec-uk#437. + employment_income: 50_070 + dividend_income: 201 + output: + meets_marriage_allowance_income_conditions: false + +- name: Pension relief comes off income once, so income at the basic rate limit after it meets the conditions (s. 55B(2)(b)) + period: 2026 + input: + # Net pay arrangement: 55,270 of earnings less 5,000 of relieved + # contributions (FA 2004 s. 188) and the 12,570 allowance leaves 37,700, + # all at the basic rate. + employment_income: 55_270 + employee_pension_contributions: 5_000 + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound above the basic rate limit after pension relief fails the conditions (s. 55B(2)(b)) + period: 2026 + input: + # 55,271 - 5,000 - 12,570 = 37,701, so 1 is at the higher rate. Adjusted + # net income (50,271) already has the relief deducted (ITA 2007 s. 58), + # so taking the allowances, which include the relief, off it would + # deduct the relief twice. + employment_income: 55_271 + employee_pension_contributions: 5_000 + output: + meets_marriage_allowance_income_conditions: false + +- name: Gift Aid extends the basic rate limit, so income up to the extended limit meets the conditions (ITA 2007 s. 414(2)(b)) + period: 2026 + input: + # A 1,600 gift grosses up to 2,000 (s. 415), extending the basic rate + # limit to 39,700. 52,270 - 12,570 = 39,700, all at the basic rate. + employment_income: 52_270 + gift_aid: 1_600 + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound above the Gift Aid-extended basic rate limit fails the conditions (ITA 2007 s. 414(2)(b)) + period: 2026 + input: + # 52,271 - 12,570 = 39,701, 1 above the extended limit of 39,700. + employment_income: 52_271 + gift_aid: 1_600 + output: + meets_marriage_allowance_income_conditions: false + +- name: Gift Aid extends the basic rate limit for the dividend test too (s. 55B(2)(ba), ITA 2007 s. 414(2)(b)) + period: 2026 input: - tax_band: INTERMEDIATE + # Earnings of 12,570 use the whole allowance, so 40,000 of dividends run + # from 0 to 40,000 of taxable income. A 3,200 gift grosses up to 4,000, + # extending the basic rate limit to 41,700, so even without the dividend + # nil rate (s. 13A) none would be at the dividend upper rate. Taking the + # gross gift off adjusted net income instead leaves nothing below the + # dividends to absorb it. + employment_income: 12_570 + dividend_income: 40_000 + gift_aid: 3_200 output: meets_marriage_allowance_income_conditions: true diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml index 7312c6d5bd..58c603f093 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml @@ -119,7 +119,7 @@ taxed_income: 47_430 personal_pension_contributions_tax: 4_000 -- name: Case 7, Marriage Allowance still comes off the band base (pinned; #1947). +- name: Case 7, Marriage Allowance leaves the band base alone (#1947). period: 2026 absolute_error_margin: 0.01 input: @@ -134,8 +134,9 @@ members: [person] country: ENGLAND output: - # earned_taxable_income subtracts Marriage Allowance, which the law gives - # as a tax reduction (ITA 2007 s. 55B), so the base is 30,000 - 12,570 - - # 1,260 = 16,170 rather than 17,430. Pinned here until #1947 is fixed. - non_savings_taxable_income: 16_170 + # Marriage Allowance received is a tax reduction at Step 6 (ITA 2007 + # s. 55A(2), s. 55B(1)), 20% x 1,260 = 252, not a cut to taxable income, + # so the base is 30,000 - 12,570 = 17,430. + non_savings_taxable_income: 17_430 non_savings_income_after_allowances: 17_430 + marriage_allowance_tax_reduction: 252 diff --git a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml index c0ada51228..179d400445 100644 --- a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml +++ b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml @@ -51,9 +51,10 @@ household: members: [high_earner, low_earner] output: - # Low earner's unused PA (12,570 − 10,000 = 2,570) is capped at PA × ma_rate - # = 12,570 × 0.1 = 1,257, then rounded up to the nearest £10 → 1,260. - # Low earner's partner has no unused PA to transfer, so low_earner's MA is 0. + # The low earner elects: the transferable amount is PA x ma_rate = + # 12,570 x 0.1 = 1,257, rounded up to the nearest £10 → 1,260, and costs + # them nothing (10,000 is below 11,310). With the income condition lifted, + # the higher-rate earner gains it; the low earner receives nothing. marriage_allowance: [1260, 0] - name: Expanded MA — explicit Marriage Allowance take-up input drives the reform @@ -153,6 +154,32 @@ output: marriage_allowance: [0, 0] +- name: Expanded MA — rate-only expansion keeps the higher-rate bar + period: 2026 + absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.cps.marriage_tax_reforms.expanded_ma_reform_rate_only + input: + people: + high_earner: + age: 35 + employment_income: 60_000 + low_earner: + age: 33 + employment_income: 8_000 + benunits: + benunit: + members: [high_earner, low_earner] + is_married: true + households: + household: + members: [high_earner, low_earner] + output: + # Without remove_income_condition the higher-rate earner still cannot + # gain (ITA 2007 s. 55B(2)(b)): 37,700 x 20% + 9,730 x 40% = 11,432. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [11_432, 0] - name: Marriage-neutral income tax — a dependant's income stays out of the couple's split # Only the spouses' incomes are pooled: (60,000 + 10,000) / 2 = 35,000 each, # which lowers their combined tax. The 19-year-old dependant at school keeps diff --git a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py index caeacd7ce2..57fe502b9b 100644 --- a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py +++ b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py @@ -24,12 +24,15 @@ one has a Marriage Allowance, and income tax is what it is at current law when no one claims the allowance. 3. With any statutory rate and the CPS parameters at their defaults, no one - in the higher or additional band meets the income conditions or has the - allowance, and no one has more than the statutory share of the personal - allowance, rounded up to the next £10. -4. The allowance never falls when the statutory rate rises. -5. Switched on by its own parameters, the reform behaves as it did before - this change (regression values computed before the fix). + liable at the higher rate or above meets the income conditions or has the + allowance, everyone else meets the conditions, and no one has more than the + statutory share of the personal allowance, rounded up to the next £10. +4. The transferable amount never falls when the statutory rate rises, and the + allowance is all of it or none. The allowance itself can fall: a couple + stops electing once giving up the larger allowance costs the elector more + than the partner gains (intended; ITA 2007 s. 55B(6)). +5. Switched on by its own parameters, the reform gives the expanded + allowance as a Step 6 tax reduction (hand-computed values below). """ import functools @@ -37,7 +40,7 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_core import periods from policyengine_core.reforms import Reform @@ -69,6 +72,12 @@ # (marriage_allowance/rounding_increment.yaml). PERSONAL_ALLOWANCE = 12_570 ROUNDING_INCREMENT = 10 +# 2025-26 taxable income above which non-savings income is charged above the +# electing rates: the basic rate limit (ITA 2007 s. 10), and for a Scottish +# taxpayer the start of the Scottish higher rate, £43,662 of income less the +# personal allowance (Scottish Rate Resolution 2025). +BASIC_RATE_LIMIT = 37_700 +SCOTTISH_HIGHER_RATE_START = 31_092 REFORM_VARIABLE = "meets_expanded_ma_conditions" # Only the reform adds it. @@ -161,19 +170,20 @@ def test_a_higher_statutory_rate_raises_only_the_statutory_allowance(route): assert outcome(simulation) == ([2_520, 0, 0, 0], [2_982, 11_432]) -# Invariant 5. Each expectation was computed on main before this change -# (ecd531776); the reform's own parameters switched it on there too. The -# expanded allowance reaches the higher-rate earner. The model takes the -# allowance off taxable income, so it is worth 40% to them: 11,432 - 1,260 x -# 40% = 10,928 at 10%, and 11,432 - 2,520 x 40% = 10,424 at 20%. +# Invariant 5, by hand. The allowance is a Step 6 tax reduction at the basic +# rate (ITA 2007 s. 55B(1), (3)), whatever the recipient's own marginal rate. +# Lifting the income condition lets the higher-rate earner gain: 11,432 - +# 1,260 x 20% = 11,180. A CPS rate alone keeps the income condition, so it +# raises the basic-rate couple's transfer to 2,520 (3,486 - 504 = 2,982) and +# leaves the higher-rate earner at 11,432. OWN_SWITCHES = { "remove_income_condition": ( change((REMOVE_INCOME_CONDITION, True)), - ([1_260, 0, 1_260, 0], [3_234, 10_928]), + ([1_260, 0, 1_260, 0], [3_234, 11_180]), ), "cps_rate": ( change((CPS_RATE, 0.2)), - ([2_520, 0, 2_520, 0], [2_982, 10_424]), + ([2_520, 0, 0, 0], [2_982, 11_432]), ), } @@ -188,11 +198,12 @@ def test_the_reform_still_switches_on_from_its_own_parameters(route, switch): def test_a_cps_rate_switches_the_reform_on_whatever_the_statutory_rate(): - # The old switch missed this: the two rates were equal. + # The old switch missed this: the two rates were equal. The income + # condition stays, so only the basic-rate earner gains. reform = change((STATUTORY_RATE, 0.2), (CPS_RATE, 0.2)) simulation = Simulation(situation=ISSUE_COUPLES, reform=reform) assert REFORM_VARIABLE in simulation.tax_benefit_system.variables - assert outcome(simulation) == ([2_520, 0, 2_520, 0], [2_982, 10_424]) + assert outcome(simulation) == ([2_520, 0, 0, 0], [2_982, 11_432]) CHILD_CONDITION_COUPLES = couples( @@ -208,9 +219,9 @@ def test_couples_outside_the_child_condition_keep_the_statutory_allowance( statutory_rate, statutory_allowance ): # With a child condition, only couples with a child under 5 get the CPS - # rate and the higher bands. The rest keep the statutory allowance: the - # basic-rate earner without a child gets the statutory rate, and the - # higher-rate earner without a child gets nothing. + # rate. The rest keep the statutory allowance: the basic-rate earner + # without a child gets the statutory rate. remove_income_condition is off, + # so neither higher-rate earner gains, with a child or without. pairs = [(CPS_RATE, 0.2), (MAX_CHILD_AGE, 5)] if statutory_rate is not None: pairs.append((STATUTORY_RATE, statutory_rate)) @@ -220,7 +231,7 @@ def test_couples_outside_the_child_condition_keep_the_statutory_allowance( statutory_allowance, 2_520, 0, - 2_520, + 0, ] others = np.delete(np.arange(len(allowance)), CHILD_CONDITION_EARNERS) assert (allowance[others] == 0).all() @@ -281,8 +292,8 @@ def test_the_reform_switches_on_from_its_own_parameters_only( # parameters, reused: each example writes its own inputs. COUPLES = 20 -# Every fourth couple lives in Scotland, whose starter and intermediate bands -# are eligible bands. +# Every fourth couple lives in Scotland, whose starter and intermediate rates +# allow an election (ITA 2007 s. 55B(2)(b)). REGIONS = ["SCOTLAND", "LONDON", "NORTH_EAST", "WALES"] @@ -337,6 +348,10 @@ def simulation_for(*pairs, reform=None): st.booleans(), # the second would claim ) drawn_couples = st.lists(couple, min_size=COUPLES, max_size=COUPLES) +# A London earner liable at the higher rate on less than a penny: £50,270.005 +# is stored as £50,270.00390625. +SUB_PENNY_ABOVE_THE_LIMIT = [(0.0, 0.0, True, True, True)] * COUPLES +SUB_PENNY_ABOVE_THE_LIMIT[1] = (50_270.005, 0.0, True, True, True) # Statutory rates in whole thousandths. 12,570 x k / 1,000 = 12.57k is a # multiple of £10 only at k = 0 and 1,000, and otherwise at least a penny from # one, so float rounding cannot move the allowance across a £10 step and the @@ -368,7 +383,6 @@ def run(simulation, drawn, anyone_claims=True): simulation.set_input("is_married", YEAR, married) simulation.set_input("would_claim_marriage_allowance", YEAR, claims) simulation.reset_calculations() - p = simulation.tax_benefit_system.parameters(YEAR) return dict( allowance=simulation.calculate("marriage_allowance", YEAR), meets_income_conditions=simulation.calculate( @@ -376,8 +390,19 @@ def run(simulation, drawn, anyone_claims=True): ), # Simulation.calculate returns an enum's names. band=np.asarray(simulation.calculate("tax_band", YEAR), dtype=str), - eligible_bands=p.gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands, + transferable=simulation.calculate( + "marriage_allowance_transferable_amount", YEAR + ), income_tax=simulation.calculate("income_tax", YEAR), + # The earners have only employment income, so all their taxable + # income is non-savings income. The personal allowance is the one in + # force, cut for a spouse who elects (s. 55B(6)). + taxable=np.maximum( + simulation.calculate("employment_income", YEAR) + - simulation.calculate("personal_allowance", YEAR), + 0, + ), + scottish=simulation.calculate("pays_scottish_income_tax", YEAR), ) @@ -400,32 +425,82 @@ def test_a_zero_statutory_rate_is_no_allowance_for_anyone(simulation_for, drawn) np.testing.assert_array_equal(abolished["income_tax"], nobody_claims["income_tax"]) +def test_taxable_income_at_the_basic_rate_limit_still_meets_the_conditions(): + # £50,270 of earnings is £37,700 of taxable income, all at the basic rate, + # so the earner can gain; tax_band calls it higher. £50,271 is liable at + # the higher rate on £1, and £50,270.005 (stored as £50,270.00390625) on + # less than a penny. The same holds at the Scottish higher rate's start, + # £43,662 of earnings in 2025-26. + situation = couples((50_270, None), (50_271, None), (50_270.005, None)) + simulation = Simulation(situation=situation) + assert simulation.calculate("marriage_allowance", YEAR).tolist() == [ + 1_260, + 0, + 0, + 0, + 0, + 0, + ] + assert np.asarray(simulation.calculate("tax_band", YEAR), dtype=str)[ + 0::2 + ].tolist() == ["HIGHER", "HIGHER", "HIGHER"] + + @SETTINGS @given(drawn_couples, permille) +@example(SUB_PENNY_ABOVE_THE_LIMIT, 100) def test_any_statutory_rate_keeps_the_statutory_conditions_and_cap( simulation_for, drawn, rate_permille ): simulation = simulation_for((STATUTORY_RATE, rate_permille / 1_000)) assert REFORM_VARIABLE not in simulation.tax_benefit_system.variables r = run(simulation, drawn) - outside = ~np.isin(r["band"], r["eligible_bands"]) - higher = np.isin(r["band"], ["HIGHER", "ADDITIONAL"]) - assert outside[higher].all() - assert not r["meets_income_conditions"][outside].any() - assert (r["allowance"][outside] == 0).all() + # Liable above the electing rates on any of their income, however little + # (ITA 2007 s. 55B(2)(b), s. 55C(1)(c)). tax_band also calls taxable + # income exactly at the limit higher, though none of it is charged at + # the higher rate (s. 10), so it is checked one way only. + limit = np.where(r["scottish"], SCOTTISH_HIGHER_RATE_START, BASIC_RATE_LIMIT) + above = r["taxable"] > limit + assert np.isin(r["band"], ["HIGHER", "ADDITIONAL"])[above].all() + assert not r["meets_income_conditions"][above].any() + assert r["meets_income_conditions"][~above].all() + assert (r["allowance"][above] == 0).all() assert (r["allowance"] >= 0).all() assert (r["allowance"] <= statutory_cap(rate_permille)).all() @settings(SETTINGS, max_examples=10) @given(drawn_couples, permille, st.sampled_from(GRID_PERMILLE)) -def test_the_allowance_never_falls_as_the_statutory_rate_rises( +def test_the_transferable_amount_never_falls_as_the_statutory_rate_rises( simulation_for, drawn, first_permille, second_permille ): lower, higher = sorted([first_permille, second_permille]) at_lower = run(simulation_for((STATUTORY_RATE, lower / 1_000)), drawn) at_higher = run(simulation_for((STATUTORY_RATE, higher / 1_000)), drawn) - assert (at_lower["allowance"] <= at_higher["allowance"]).all() + assert (at_lower["transferable"] <= at_higher["transferable"]).all() + for r in (at_lower, at_higher): + received = r["allowance"] > 0 + np.testing.assert_array_equal( + r["allowance"][received], r["transferable"][received] + ) + + +def test_a_couple_can_stop_electing_as_the_statutory_rate_rises(): + # Intended: the allowance is not monotone in the rate. Earnings of £20,000 + # and £12,000 (2025-26). At 10% the £12,000 earner gives up 1,260 of + # allowance, paying 20% x (12,000 - 11,310) = 138 for the partner's 252. + # At 100% they give up all 12,570 and would pay 2,400 for a reduction + # capped at the partner's tax, 20% x (20,000 - 12,570) = 1,486, so no one + # elects. + situation = couples((20_000, None)) + situation["people"]["partner_0"]["employment_income"] = {YEAR: 12_000} + at_ten = Simulation(situation=situation) + at_full = Simulation(situation=situation, reform=change((STATUTORY_RATE, 1.0))) + assert at_ten.calculate("marriage_allowance", YEAR).tolist() == [1_260, 0] + assert at_full.calculate("marriage_allowance", YEAR).tolist() == [0, 0] + assert at_full.calculate( + "marriage_allowance_transferable_amount", YEAR + ).tolist() == [12_570, 12_570] @SETTINGS diff --git a/policyengine_uk/tests/test_marriage_allowance_election_branches.py b/policyengine_uk/tests/test_marriage_allowance_election_branches.py new file mode 100644 index 0000000000..4aba6316f0 --- /dev/null +++ b/policyengine_uk/tests/test_marriage_allowance_election_branches.py @@ -0,0 +1,310 @@ +"""Engineering checks for the branches behind the Marriage Allowance election. + +makes_marriage_allowance_election weighs each couple's income tax in two +policyengine-core branches (ITA 2007 ss. 55B, 55C), and uses_property_allowance +weighs income tax in branches of its own (ITTOIA 2005 s. 783BL), so the two +nest whenever a spouse has property income with finance costs. For any +generated population of couples and singles: + +1. Determinism under query order: the election, the Marriage Allowance + reduction, personal allowances, income tax, the property allowance route, + the finance-cost reduction, Universal Credit and household net income are + bit-for-bit the same whichever of them is asked for first, with and without + a reform to the personal allowance dated to the year. +2. Baseline isolation: a reformed simulation's baseline gives bit-for-bit the + same election, reduction, allowances and income tax as a simulation with no + reform, whether the reform or the baseline is computed first, and the + reform's own results do not depend on that order either. +3. Branches are dropped: computing six years leaves no branch behind. +4. Branches are released: once the election is made, its two branches hold + no values, though policyengine-core keeps them until the calculations + under way end, and the simulation keeps its own. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.utils.aggregate_impact.guard import discard_formula_results +from policyengine_uk.utils.scenario import Scenario + +PROPERTY_SETTINGS = settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +YEARS = [2026, 2027] +ELECTION = "makes_marriage_allowance_election" +OUTPUTS = [ + ELECTION, + "marriage_allowance_tax_reduction", + "personal_allowance", + "income_tax", + "uses_property_allowance", + "property_finance_cost_relief", + "universal_credit", + "household_net_income", +] +# Each order asks for one output first; the rest follow in list order. +FIRST = [ + ELECTION, + "universal_credit", + "income_tax", + "uses_property_allowance", + "personal_allowance", +] +BASELINE_OUTPUTS = [ + ELECTION, + "marriage_allowance_transferable_amount", + "marriage_allowance_tax_reduction", + "personal_allowance", + "income_tax", +] +TENURES = ["RENT_FROM_COUNCIL", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] +REGIONS = ["LONDON", "WALES", "SCOTLAND"] +# A couple who elect every year: 30,000 and 9,000 of earnings, so electing +# costs the lower earner nothing and saves the other 252 (s. 55B). The lower +# earner's property receipts are within the property allowance and carry +# finance costs, so the property route is weighed in branches too. +ELECTING_COUPLE = [ + dict( + adults=[ + dict(age=40, employment_income=30_000.0), + dict( + age=38, + employment_income=9_000.0, + property_rental_income=800.0, + property_income=800.0, + property_finance_costs=200.0, + ), + ], + married=True, + children=[4], + tenure="RENT_PRIVATELY", + rent=9_000.0, + region="LONDON", + ) +] + + +def personal_allowance_reform(year): + # A 15,000 allowance for this tax year only (a bare year is the UK fiscal + # year), so the transferable amount is 1,500 (s. 55B(4)(b), (5)). + return Scenario( + parameter_changes={ + "gov.hmrc.income_tax.allowances.personal_allowance.amount": { + str(year): 15_000 + } + } + ) + + +def amounts(cap): + return st.one_of(st.just(0.0), st.floats(0, cap)) + + +@st.composite +def adults(draw): + adult = dict( + age=draw(st.integers(18, 80)), + employment_income=draw(amounts(50_000)), + savings_interest_income=draw(amounts(10_000)), + dividend_income=draw(amounts(5_000)), + property_income=draw(amounts(15_000)), + property_finance_costs=draw(amounts(10_000)), + ) + # Gross receipts are the profit plus expenses; with expenses under the + # 1,000 property allowance, and finance costs, the route is a choice + # weighed in branches (ITTOIA 2005 s. 783BH, s. 783BL). + adult["property_rental_income"] = adult["property_income"] + draw(amounts(1_500)) + if adult["age"] >= 67: + adult["state_pension"] = draw(amounts(15_000)) + return adult + + +@st.composite +def families(draw): + return dict( + adults=[draw(adults()) for _ in range(draw(st.sampled_from([1, 2, 2])))], + married=draw(st.sampled_from([True, True, False])), + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + tenure=draw(st.sampled_from(TENURES)), + rent=draw(amounts(15_000)), + region=draw(st.sampled_from(REGIONS)), + ) + + +# Small populations: every adult may have property receipts and finance +# costs, so most simulations run both kinds of branch. +populations = st.lists(families(), min_size=1, max_size=3) + + +def situation(units, years): + """One simulation holding every family, with the same inputs each year.""" + years = [years] if isinstance(years, int) else years + + def each_year(value): + return {year: value for year in years} + + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + person = {variable: each_year(value) for variable, value in adult.items()} + person["is_claimant_or_partner"] = each_year(True) + people[f"p{i}_{j}"] = person + names.append(f"p{i}_{j}") + for k, age in enumerate(unit["children"]): + people[f"c{i}_{k}"] = { + "age": each_year(age), + "is_claimant_or_partner": each_year(False), + } + names.append(f"c{i}_{k}") + benunits[f"b{i}"] = { + "members": names, + "is_married": each_year(unit["married"] and len(unit["adults"]) == 2), + } + households[f"h{i}"] = { + "members": names, + "rent": each_year(unit["rent"]), + "tenure_type": each_year(unit["tenure"]), + "region": each_year(unit["region"]), + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(simulation, year, variables, first=None): + order = [first] + [v for v in variables if v != first] if first else variables + for variable in order: + simulation.calculate(variable, year) + return {v: np.asarray(simulation.calculate(v, year)) for v in variables} + + +def assert_identical(a, b, message): + for variable in a: + assert a[variable].dtype == b[variable].dtype, (variable, message) + assert np.array_equal(a[variable], b[variable]), (variable, message) + + +@PROPERTY_SETTINGS +@given( + units=populations, + year=st.sampled_from(YEARS), + reformed=st.booleans(), +) +def test_results_do_not_depend_on_query_order(units, year, reformed): + scenario = personal_allowance_reform(year) if reformed else None + results = [ + calculate( + Simulation(situation=situation(units, year), scenario=scenario), + year, + OUTPUTS, + first=first, + ) + for first in FIRST + ] + for first, result in zip(FIRST[1:], results[1:]): + assert_identical(results[0], result, f"{first} first: {units}") + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_reform_leaves_the_baseline_unchanged(units, year): + plain = calculate( + Simulation(situation=situation(units, year)), year, BASELINE_OUTPUTS + ) + # Reform first, then its baseline. + reformed = Simulation( + situation=situation(units, year), scenario=personal_allowance_reform(year) + ) + reform_first = calculate(reformed, year, BASELINE_OUTPUTS) + baseline_second = calculate(reformed.baseline, year, BASELINE_OUTPUTS) + # Baseline first, then the reform. + reformed = Simulation( + situation=situation(units, year), scenario=personal_allowance_reform(year) + ) + baseline_first = calculate(reformed.baseline, year, BASELINE_OUTPUTS) + reform_second = calculate(reformed, year, BASELINE_OUTPUTS) + assert_identical(plain, baseline_second, f"baseline after reform: {units}") + assert_identical(plain, baseline_first, f"baseline before reform: {units}") + assert_identical(reform_first, reform_second, f"reform: {units}") + # The reform reached the simulation: the transferable amount is 10% of + # 15,000 (s. 55B(4)(b)), and 1,260 in the baseline. + assert np.all(reform_first["marriage_allowance_transferable_amount"] == 1_500) + assert np.all(plain["marriage_allowance_transferable_amount"] == 1_260) + + +def test_no_branch_is_left_after_any_year(): + years = list(range(2025, 2031)) + simulation = Simulation(situation=situation(ELECTING_COUPLE, years)) + before = set(simulation.branches) + elected = [] + for year in years: + simulation.calculate("household_net_income", year) + elected.append(bool(simulation.calculate(ELECTION, year).any())) + assert set(simulation.branches) == before, (year, list(simulation.branches)) + assert all(elected), elected + + +def test_the_election_releases_its_branches(monkeypatch): + # Each branch would otherwise hold its copies of the simulation's arrays, + # and its own results, until household net income is calculated. + year = 2026 + simulation = Simulation(situation=situation(ELECTING_COUPLE, year)) + get_branch = type(simulation).get_branch + made = [] + + def recording_get_branch(self, name="branch", clone_system=False): + branch = get_branch(self, name, clone_system) + if self is simulation and name.startswith("marriage_allowance_"): + made.append(branch) + return branch + + monkeypatch.setattr(type(simulation), "get_branch", recording_get_branch) + simulation.calculate("household_net_income", year) + assert simulation.calculate(ELECTION, year).any() + assert len(made) == 2 + for branch in made: + assert not any(p._holders for p in branch.populations.values()) + assert not branch._fast_cache + # The simulation keeps the values it calculated before branching. + assert simulation.get_array("adjusted_net_income", year) is not None + + +@pytest.mark.parametrize( + "tax", ["income_tax", "income_tax_before_winter_fuel_payment_charge"] +) +def test_final_tax_input_does_not_change_the_election_or_capital_gains_tax(tax): + # A tax override is an accounting perturbation, not an economic change. + # Private election counterfactuals must still compare formula tax before + # the winter fuel payment charge, while the caller's supplied tax remains + # authoritative in the real sim. + units = [ + dict( + adults=[ + dict(age=40, employment_income=20_000.0, capital_gains=50_000.0), + dict(age=38, employment_income=20_000.0, capital_gains=50_000.0), + ], + married=True, + children=[], + tenure="OWNED_OUTRIGHT", + rent=0.0, + region="LONDON", + ) + ] + year = 2026 + simulation = Simulation(situation=situation(units, year)) + variables = [ELECTION, "personal_allowance", "capital_gains_tax"] + before = {name: simulation.calculate(name, year).copy() for name in variables} + assert not before[ELECTION].any() + supplied_tax = simulation.calculate(tax, year).copy() + 1 + branches = set(simulation.branches) + assert discard_formula_results(simulation) + simulation.set_input(tax, year, supplied_tax) + after = calculate(simulation, year, variables) + assert_identical(before, after, f"only {tax} was overridden") + np.testing.assert_array_equal(simulation.calculate(tax, year), supplied_tax) + assert set(simulation.branches) == branches diff --git a/policyengine_uk/tests/test_marriage_allowance_properties.py b/policyengine_uk/tests/test_marriage_allowance_properties.py new file mode 100644 index 0000000000..b59edc9282 --- /dev/null +++ b/policyengine_uk/tests/test_marriage_allowance_properties.py @@ -0,0 +1,581 @@ +"""Property-based tests for the Marriage Allowance (ITA 2007 ss. 55A-55E). + +An election by one spouse or civil partner cuts their own personal allowance +by the transferable amount (s. 55B(6)) and gives the other a Step 6 tax +reduction of the appropriate percentage of it, capped at their Step 5 tax +(s. 55A(2), s. 55B(1), (3), s. 29(2)). The couple elects only when the +conditions allow it and it lowers their combined income tax before the winter +fuel payment charge, which is what the election weighs: the charge can differ +between the two positions only through benefits, which the election does not +weigh (an intended modelling choice; WINTER_FUEL_COUPLE pins a case where +electing starts the charge). "Income tax" below means that tax. + +Invariants, for any generated population of couples with every kind of +taxable income, some with residential finance costs, in England, Wales or +Scotland: + +1. The couple's income tax is never higher than with no election. +2. Differential against brute force: the model's election gives the lowest + couple income tax of no election, the first spouse electing and the second + spouse electing, among those the conditions allow, each run with the + election fixed. +3. Once a spouse has elected, the gaining partner's reduction, allowance and + income tax do not depend on the electing spouse's income, and the electing + spouse's allowance falls by exactly the transferable amount. +4. Accounting: in each couple what one spouse gives up the other receives, at + most one spouse elects, the gaining partner's tax falls by exactly the + reduction, and the reduction is at most the appropriate percentage of the + transferable amount and the tax left to reduce. +5. The couple's income tax, with the election the model makes, never falls + when either spouse has more income. +6. No one outside a married couple gives up or receives anything. +7. Differential against the tax bands: outside Scotland, with no dividends or + property income, a person meets the income conditions exactly when the + model's own band variables put none of their income above the basic rate + limit, with pension relief and Gift Aid (which extends the limits, ITA + 2007 s. 414(2)(b)) in play. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 2019 has a 1,250 transferable amount, 2026 current rates and the 10.75% +# dividend ordinary rate, 2027 the property and savings rates (Finance Act +# 2026). +YEARS = [2019, 2026, 2027] +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] +INCOMES = { + "employment_income": 60_000, + "self_employment_income": 20_000, + "private_pension_income": 20_000, + "state_pension": 15_000, + "savings_interest_income": 20_000, + "dividend_income": 15_000, + "property_income": 10_000, +} +# Residential finance costs give the landlord a Step 6 reduction of their own +# (ITTOIA 2005 s. 274AA), deducted before the Marriage Allowance reduction. +# They also make the property allowance a choice that income tax weighs in +# branches of its own, which is slow, so about a quarter of the generated +# populations have them, and LANDLORD_COUPLE is always tried. +FINANCE_COSTS = 10_000 +# A landlord whose finance-cost reduction clears their tax, married to a +# low earner who would pay 138 to elect: electing saves the landlord nothing. +LANDLORD_COUPLE = [ + dict( + adults=[ + dict(age=40, property_income=20_000.0, property_finance_costs=15_000.0), + dict(age=38, employment_income=12_000.0), + ], + married=True, + children=[], + region="LONDON", + married_couples_allowance=0.0, + ) +] +# A low earner whose small lettings are within the property allowance. Full +# relief and an election save the couple 114; electing out of the allowance +# for the finance-cost reduction saves the low earner 132 but stops the +# election, costing the couple 120 more. The route must be weighed on the +# couple's tax (independent review of #1963, 12 Oct 2026). +SMALL_LETTINGS_COUPLE = [ + dict( + adults=[ + dict(age=40, employment_income=30_000.0), + dict( + age=38, + employment_income=12_000.0, + property_income=800.0, + property_rental_income=800.0, + property_finance_costs=200.0, + ), + ], + married=True, + children=[], + region="LONDON", + married_couples_allowance=0.0, + ) +] +ELECTION = "makes_marriage_allowance_election" +# The tax the election weighs (makes_marriage_allowance_election.COMPARED_TAX). +TAX = "income_tax_before_winter_fuel_payment_charge" +# A mixed-age couple with children (2026). Electing saves 114 before the +# charge, which ends their £1 of Universal Credit, a relevant benefit (ITEPA +# 2003 s. 681I(4)), so the elder's £200 winter fuel payment is charged back: +# income tax including the charge rises from 7,002.26 to 7,088.26. +WINTER_FUEL_COUPLE = [ + dict( + adults=[ + dict( + age=70, + employment_income=28_581.31818, + savings_interest_income=20_000.0, + ), + dict(age=45, employment_income=12_000.0), + ], + married=True, + children=[12, 8], + region="LONDON", + married_couples_allowance=0.0, + ) +] +VARIABLES = [ + ELECTION, + "is_marriage_allowance_spouse", + "marriage_allowance_transferable_amount", + "marriage_allowance_relinquished", + "marriage_allowance", + "marriage_allowance_appropriate_percentage", + "marriage_allowance_tax_reduction", + "marriage_allowance_tax_reduction_limit", + "meets_marriage_allowance_income_conditions", + "personal_allowance", + TAX, + "income_tax", +] +TOLERANCE = 0.01 + + +def amounts(cap): + # Many incomes are zero, so the allowances and bands are often unused. + return st.one_of(st.just(0.0), st.floats(0, cap)) + + +@st.composite +def adults(draw, finance_costs=False): + age = draw(st.integers(18, 100)) + adult = dict(age=age) + for variable, cap in INCOMES.items(): + if variable == "state_pension" and age < 67: + continue + adult[variable] = draw(amounts(cap)) + if finance_costs: + adult["property_finance_costs"] = draw(amounts(FINANCE_COSTS)) + return adult + + +@st.composite +def families(draw, finance_costs=False): + return dict( + adults=[ + draw(adults(finance_costs)) + for _ in range(draw(st.sampled_from([1, 2, 2, 2]))) + ], + married=draw(st.sampled_from([True, True, True, False])), + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + region=draw(st.sampled_from(REGIONS)), + # s. 55B(2)(d): now and then the first adult claims married couple's + # allowance. + married_couples_allowance=draw(st.sampled_from([0.0] * 9 + [11_270.0])), + ) + + +def situation(units, year, election=None, bump=None): + """One simulation holding every family. + + ``election``, one value per person in order, fixes who elects. + ``bump`` is (family index, adult index, variable, amount) to add. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + # The roles are given, not inferred: inference from ages alone + # can read a couple aged 18 and 100 as parent and child. + person = { + "state_pension": {year: 0.0}, + "is_claimant_or_partner": {year: True}, + } + for variable, value in adult.items(): + person[variable] = {year: value} + if j == 0: + person["married_couples_allowance"] = { + year: unit["married_couples_allowance"] + } + if bump is not None and bump[:2] == (i, j): + person[bump[2]] = {year: adult.get(bump[2], 0.0) + bump[3]} + people[f"p{i}_{j}"] = person + names.append(f"p{i}_{j}") + for k, age in enumerate(unit["children"]): + people[f"c{i}_{k}"] = { + "age": {year: age}, + "is_claimant_or_partner": {year: False}, + } + names.append(f"c{i}_{k}") + benunits[f"b{i}"] = { + "members": names, + "is_married": {year: unit["married"] and len(unit["adults"]) == 2}, + } + households[f"h{i}"] = {"members": names, "region": {year: unit["region"]}} + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, year, **kwargs): + sim = Simulation(situation=situation(units, year, **kwargs)) + values = {v: np.asarray(sim.calculate(v, year)) for v in VARIABLES} + values["benunit"] = np.asarray(sim.populations["benunit"].members_entity_id) + return values + + +def per_person(units, adult_value, child_value=0.0): + """One value per person in input order: adults, then children.""" + return np.array( + [ + value + for unit in units + for value in [adult_value(unit, j) for j in range(len(unit["adults"]))] + + [child_value] * len(unit["children"]) + ] + ) + + +def couple_total(values, variable): + """Each person's couple total of ``variable``.""" + total = np.bincount(values["benunit"], weights=values[variable]) + return total[values["benunit"]] + + +def spouses_by_order(values): + """Masks for the first and second spouse of each couple, in input order.""" + spouse = values["is_marriage_allowance_spouse"] + first = np.zeros_like(spouse) + second = np.zeros_like(spouse) + seen = set() + for i, (is_spouse, unit) in enumerate(zip(spouse, values["benunit"])): + if not is_spouse: + continue + if unit in seen: + second[i] = True + else: + first[i] = True + seen.add(unit) + return first, second + + +@st.composite +def populations(draw): + finance_costs = draw(st.sampled_from([False, False, False, True])) + return draw(st.lists(families(finance_costs), min_size=1, max_size=8)) + + +couple_populations = populations() + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +@example(units=LANDLORD_COUPLE, year=2026) +@example(units=SMALL_LETTINGS_COUPLE, year=2026) +@example(units=WINTER_FUEL_COUPLE, year=2026) +def test_election_never_raises_the_couples_tax(units, year): + model = calculate(units, year) + none = calculate(units, year, election=np.zeros_like(model[ELECTION])) + model_tax = couple_total(model, TAX) + none_tax = couple_total(none, TAX) + assert np.all(model_tax <= none_tax + TOLERANCE), units + # A couple that elects saves at least a penny. + elects = couple_total(model, ELECTION) > 0 + assert np.all(model_tax[elects] <= none_tax[elects] - TOLERANCE + 1e-6), units + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +@example(units=LANDLORD_COUPLE, year=2026) +@example(units=SMALL_LETTINGS_COUPLE, year=2026) +@example(units=WINTER_FUEL_COUPLE, year=2026) +def test_election_is_the_best_allowed_choice(units, year): + model = calculate(units, year) + none = calculate(units, year, election=np.zeros_like(model[ELECTION])) + first, second = spouses_by_order(model) + options = [none] + allowed = [np.ones_like(first)] + claims_mca = per_person( + units, lambda unit, j: float(j == 0 and unit["married_couples_allowance"] > 0) + ) + no_mca = couple_total(dict(benunit=model["benunit"], mca=claims_mca), "mca") == 0 + for elector in [first, second]: + run = calculate(units, year, election=elector) + gainer = model["is_marriage_allowance_spouse"] & ~elector + # s. 55C(1)(b), (c): the elector has an allowance and, with it cut, + # pays only basic rates; s. 55B(2)(b): so does the gaining partner; + # s. 55B(2)(d): no married couple's allowance. + conditions = ( + elector + & (none["personal_allowance"] > 0) + & run["meets_marriage_allowance_income_conditions"] + ) | (gainer & none["meets_marriage_allowance_income_conditions"]) + couple_ok = ( + couple_total(dict(benunit=model["benunit"], ok=conditions), "ok") == 2 + ) & no_mca + options.append(run) + allowed.append(couple_ok) + best = couple_total(none, TAX) + for run, ok in zip(options[1:], allowed[1:]): + best = np.where(ok, np.minimum(best, couple_total(run, TAX)), best) + np.testing.assert_allclose( + couple_total(model, TAX), best, atol=TOLERANCE, err_msg=str(units) + ) + + +@st.composite +def bumped(draw): + units = draw(populations()) + i = draw(st.integers(0, len(units) - 1)) + j = draw(st.integers(0, len(units[i]["adults"]) - 1)) + variables = [ + v for v in INCOMES if v != "state_pension" or units[i]["adults"][j]["age"] >= 67 + ] + return units, (i, j, draw(st.sampled_from(variables)), draw(st.floats(1, 30_000))) + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_gaining_partner_is_independent_of_the_electors_income(case, year): + units, (i, j, variable, amount) = case + base = calculate(units, year) + # The bumped adult elects wherever they are a spouse. + person_index = ( + sum(len(unit["adults"]) + len(unit["children"]) for unit in units[:i]) + j + ) + election = np.zeros_like(base[ELECTION]) + election[person_index] = base["is_marriage_allowance_spouse"][person_index] + low = calculate(units, year, election=election) + high = calculate(units, year, election=election, bump=(i, j, variable, amount)) + others = np.arange(len(election)) != person_index + for variable_name in [ + "marriage_allowance", + "marriage_allowance_tax_reduction", + "personal_allowance", + TAX, + ]: + np.testing.assert_allclose( + high[variable_name][others], + low[variable_name][others], + atol=TOLERANCE, + err_msg=f"{variable_name} {case}", + ) + # s. 55B(6): the elector's allowance falls by the transferable amount. + none = calculate(units, year, election=np.zeros_like(election)) + for run in [low]: + np.testing.assert_allclose( + run["personal_allowance"], + np.maximum( + 0, + none["personal_allowance"] + - election * run["marriage_allowance_transferable_amount"], + ), + atol=TOLERANCE, + err_msg=str(case), + ) + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +def test_marriage_allowance_accounting(units, year): + model = calculate(units, year) + none = calculate(units, year, election=np.zeros_like(model[ELECTION])) + spouse = model["is_marriage_allowance_spouse"] + elects = model[ELECTION] + # Only spouses elect, at most one per couple. + assert not np.any(elects & ~spouse), units + assert np.all(couple_total(model, ELECTION) <= 1), units + # What one spouse gives up the other receives. + np.testing.assert_allclose( + couple_total(model, "marriage_allowance"), + couple_total(model, "marriage_allowance_relinquished"), + atol=TOLERANCE, + ) + transferable = model["marriage_allowance_transferable_amount"] + received = model["marriage_allowance"] + assert np.all(np.isclose(received, 0) | np.isclose(received, transferable)), units + # The reduction is bounded by s. 55B(1), (3) and s. 29(2). + reduction = model["marriage_allowance_tax_reduction"] + rate = model["marriage_allowance_appropriate_percentage"] + assert np.all(reduction <= rate * received + TOLERANCE), units + assert np.all( + reduction <= model["marriage_allowance_tax_reduction_limit"] + TOLERANCE + ), units + # The gaining partner's tax falls by exactly the reduction. + gainer = received > 0 + np.testing.assert_allclose( + model[TAX][gainer], + none[TAX][gainer] - reduction[gainer], + atol=TOLERANCE, + err_msg=str(units), + ) + # The elector pays at least as much as without the election. + assert np.all(model[TAX][elects] >= none[TAX][elects] - TOLERANCE), units + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_couples_tax_never_falls_with_more_income(case, year): + units, bump = case + low = calculate(units, year) + high = calculate(units, year, bump=bump) + assert np.all(couple_total(high, TAX) >= couple_total(low, TAX) - TOLERANCE), ( + bump, + units, + ) + + +def test_electing_can_start_the_winter_fuel_payment_charge(): + # Intended: the election weighs tax before the charge, and does not weigh + # what it does to benefits (see the module docstring). + model = calculate(WINTER_FUEL_COUPLE, 2026) + none = calculate(WINTER_FUEL_COUPLE, 2026, election=np.zeros_like(model[ELECTION])) + assert model[ELECTION].tolist() == [False, True, False, False] + np.testing.assert_allclose(couple_total(model, TAX)[0], 6_888.26, atol=TOLERANCE) + np.testing.assert_allclose(couple_total(none, TAX)[0], 7_002.26, atol=TOLERANCE) + np.testing.assert_allclose( + couple_total(model, "income_tax")[0], 7_088.26, atol=TOLERANCE + ) + np.testing.assert_allclose( + couple_total(none, "income_tax")[0], 7_002.26, atol=TOLERANCE + ) + + +# A married couple aged 18 and 100 with no income: the review's +# counterexample when roles were inferred from ages. +COUPLE_18_AND_100 = [ + dict( + adults=[ + dict( + age=18, + **{ + variable: 0.0 for variable in INCOMES if variable != "state_pension" + }, + ), + dict(age=100, **{variable: 0.0 for variable in INCOMES}), + ], + married=True, + children=[], + region=REGIONS[0], + married_couples_allowance=0.0, + ) +] + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +@example(units=COUPLE_18_AND_100, year=2026) +def test_only_married_couples_transfer(units, year): + model = calculate(units, year) + outside = ~model["is_marriage_allowance_spouse"] + for variable in [ + ELECTION, + "marriage_allowance", + "marriage_allowance_relinquished", + "marriage_allowance_tax_reduction", + ]: + assert np.all(model[variable][outside] == 0), (variable, units) + married_pairs = { + i + for i, unit in enumerate(units) + if unit["married"] and len(unit["adults"]) == 2 + } + spouse_units = set(model["benunit"][model["is_marriage_allowance_spouse"]]) + assert spouse_units == married_pairs, units + + +# Incomes the rate test and the band variables both see. Dividends (tested as +# if the dividend nil rate were omitted, s. 55B(2)(ba)) and property income +# (banded inside property_income_tax) have no band variables to compare with. +BANDED_INCOMES = { + "employment_income": 160_000, + "private_pension_income": 30_000, + "savings_interest_income": 20_000, + # Net pay arrangement and relief at source (FA 2004 ss. 188-192). + "employee_pension_contributions": 20_000, + "personal_pension_contributions": 10_000, + "gift_aid": 10_000, +} +ABOVE_BASIC_RATE = [ + "higher_rate_earned_income", + "add_rate_earned_income", + "higher_rate_savings_income", + "add_rate_savings_income", +] +# At the basic rate limit after pension relief, and 1 above it. +PENSION_SAVERS = [ + dict(employment_income=55_270.0, employee_pension_contributions=5_000.0), + dict(employment_income=55_271.0, employee_pension_contributions=5_000.0), +] + + +@st.composite +def taxpayers(draw): + return {variable: draw(amounts(cap)) for variable, cap in BANDED_INCOMES.items()} + + +@PROPERTY_SETTINGS +@given( + people=st.lists(taxpayers(), min_size=1, max_size=20), year=st.sampled_from(YEARS) +) +@example(people=PENSION_SAVERS, year=2026) +def test_income_conditions_match_the_tax_bands(people, year): + # The rate test (s. 55B(2)(b), s. 55C(1)(c)) must use the income the tax + # bands use (net income less allowances, so pension relief once) and the + # same Gift Aid-extended limits. + situation = {"people": {}, "benunits": {}, "households": {}} + for i, incomes in enumerate(people): + person = {variable: {year: value} for variable, value in incomes.items()} + situation["people"][f"p{i}"] = {"age": {year: 40}, **person} + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = { + "members": [f"p{i}"], + "region": {year: "LONDON"}, + } + sim = Simulation(situation=situation) + above_basic = sum(np.asarray(sim.calculate(v, year)) for v in ABOVE_BASIC_RATE) + meets = np.asarray( + sim.calculate("meets_marriage_allowance_income_conditions", year) + ) + np.testing.assert_array_equal(meets, above_basic <= 0, err_msg=str(people)) + + +def test_2015_transferable_amount_is_fixed_whatever_the_allowance(): + # ITA 2007 s. 55B(4)(a) fixes 2015-16 at 1,060; only later years take 10% + # of the s. 35(1) allowance. With the 2015 allowance raised to 15,000 the + # transfer is still 1,060: the transferor's allowance falls to 13,940 and + # the recipient on 30,000 pays (30,000 - 15,000) x 20% - 212 = 2,788. + year = 2015 + situation = { + "people": { + "recipient": {"age": {year: 40}, "employment_income": {year: 30_000}}, + "transferor": {"age": {year: 38}}, + }, + "benunits": { + "couple": { + "members": ["recipient", "transferor"], + "is_married": {year: True}, + } + }, + "households": {"home": {"members": ["recipient", "transferor"]}}, + } + sim = Simulation( + situation=situation, + reform={ + "gov.hmrc.income_tax.allowances.personal_allowance.amount": { + "2015-01-01.2015-12-31": 15_000 + } + }, + ) + np.testing.assert_allclose( + sim.calculate("marriage_allowance_transferable_amount", year), [1_060, 1_060] + ) + np.testing.assert_allclose( + sim.calculate("personal_allowance", year), [15_000, 13_940] + ) + np.testing.assert_allclose(sim.calculate("income_tax", year), [2_788, 0]) diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index fa856dc008..3b19091a60 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -8,9 +8,10 @@ - each person's own earnings, each source floored at nil, less their primary Class 1 contributions, half of their pension contributions and, on self-employed profit, the notional tax and contributions of CER 1996 - reg. 14 (basic rate on the profit less the personal allowance and any - marriage allowance; main-rate Class 4 under State Pension age), floored - at nil; + reg. 14 (basic rate on the profit less the personal allowance, itself + cut by any Marriage Allowance the person gives up (ITA 2007 s. 55B(6)), + and less any Marriage Allowance they receive; main-rate Class 4 under + State Pension age), floored at nil; - less the income tax payable on the claimant's and partner's employed earnings, measured as the fall in their income tax when the household is simulated again with nobody's employment income. @@ -35,7 +36,7 @@ """ import numpy as np -from hypothesis import HealthCheck, assume, given, settings +from hypothesis import HealthCheck, assume, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -155,14 +156,19 @@ def weekly_amount(people): return 20 if (disabled or carer or lone_parent) else (10 if couple else 5) -def notional_deductions(person, marriage_allowance): - """CER 1996 reg. 14 on the person's profit, from the inputs: no - generated adult is blind-person's-allowance relieved, born before 1935 - or liable for Class 2 (abolished in 2024-25).""" +def notional_deductions(person, relinquished, marriage_allowance): + """CER 1996 reg. 14 on the person's profit, from the inputs and the + couple's Marriage Allowance election: no generated adult is + blind-person's-allowance relieved, born before 1935 or liable for Class 2 + (abolished in 2024-25). The relief appropriate to the person's + circumstances is their personal allowance less any allowance they give up + (ITA 2007 s. 55B(6)). The allowance they receive comes off the profit + here, which at the basic rate is the same as the s. 55B(1) reduction.""" profit = max(0, person.get("self_employment_income", 0)) if profit == 0: return 0.0 - tax = BASIC_RATE * max(0, profit - PERSONAL_ALLOWANCE - marriage_allowance) + allowance = PERSONAL_ALLOWANCE - relinquished + tax = BASIC_RATE * max(0, profit - allowance - marriage_allowance) class_4 = 0.0 if person["age"] < STATE_PENSION_AGE: class_4 = CLASS_4_MAIN_RATE * min( @@ -172,7 +178,43 @@ def notional_deductions(person, marriage_allowance): return tax + class_4 +# A couple over State Pension age with £12,000 and £15,000 of profit (2024-25), +# who would claim. If the first gives up £1,260 of allowance they pay 20% x +# (12,000 - 11,310) = 138 for the partner's 252 reduction, so they elect. The +# transferor's notional tax is 138, not 0; the recipient's is 20% x (15,000 - +# 12,570 - 1,260) = 234. +ELECTING_COUPLE = { + "people": { + "claimant": { + "age": 67, + "self_employment_income": 12_000, + "would_claim_marriage_allowance": True, + }, + "partner": { + "age": 67, + "self_employment_income": 15_000, + "would_claim_marriage_allowance": True, + }, + }, + "country": "ENGLAND", +} + + +def test_the_transferor_s_notional_tax_uses_the_cut_allowance(): + sim = simulate(ELECTING_COUPLE) + assert person_values(sim, "marriage_allowance_relinquished").tolist() == [ + 1_260, + 0, + ] + assert person_values(sim, "marriage_allowance").tolist() == [0, 1_260] + assert person_values(sim, "pension_credit_self_employment_deductions").tolist() == [ + 138, + 234, + ] + + @SETTINGS +@example(ELECTING_COUPLE) @given(units()) def test_statute_bounds_differential_and_monotonicity(case): people = case["people"] @@ -186,10 +228,11 @@ def test_statute_bounds_differential_and_monotonicity(case): [max(0, people[n].get("self_employment_income", 0)) for n in names], dtype=float, ) + relinquished = person_values(sim, "marriage_allowance_relinquished") marriage_allowance = person_values(sim, "marriage_allowance") notional = np.array( [ - notional_deductions(people[n], marriage_allowance[i]) + notional_deductions(people[n], relinquished[i], marriage_allowance[i]) for i, n in enumerate(names) ] ) diff --git a/policyengine_uk/tests/test_pension_credit_notional_class2.py b/policyengine_uk/tests/test_pension_credit_notional_class2.py index 9e177477da..0a6641d658 100644 --- a/policyengine_uk/tests/test_pension_credit_notional_class2.py +++ b/policyengine_uk/tests/test_pension_credit_notional_class2.py @@ -49,9 +49,13 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): "claimant": { "age": {2023: 70}, "attendance_allowance": {2023: 3_000}, + "marital_status": {2023: "MARRIED"}, + "would_claim_marriage_allowance": {2023: False}, }, "partner": { "age": {2023: 60}, + "marital_status": {2023: "MARRIED"}, + "would_claim_marriage_allowance": {2023: False}, "dla_sc_category": {2023: "MIDDLE"}, "self_employment_income": {2023: 20_000}, "trading_loss": {2023: loss}, @@ -60,6 +64,7 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): "benunits": { "couple": { "members": ["claimant", "partner"], + "is_married": {2023: True}, "has_mixed_age_couple_pension_credit_saving": {2023: True}, "would_claim_pc": {2023: True}, } @@ -72,12 +77,13 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): assert actual_class2 == pytest.approx(0 if loss else 179.40, abs=0.01) # CER 13(12) prohibits another employment's loss reducing the # £20,000 positive profit. Notional deductions are therefore - # (£20,000 - £12,570 PA - £1,260 marriage allowance) * 20% = - # £1,234 tax; plus £668.70 Class 4 and £179.40 Class 2 = £2,082.10. + # (£20,000 - £12,570 PA) * 20% = £1,486 tax, with Marriage Allowance + # takeup explicitly off to isolate the notional-Class-2 property; + # plus £668.70 Class 4 and £179.40 Class 2 = £2,334.10. deductions = simulation.calculate( "pension_credit_self_employment_deductions", 2023 )[1] - assert deductions == pytest.approx(2_082.10, abs=0.01) + assert deductions == pytest.approx(2_334.10, abs=0.01) results.append( ( deductions, @@ -88,9 +94,9 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): # SI 2023/316 art.30(2)(a), (d): the couple guarantee plus the higher # severe-disability addition is (£306.85 + £152.80) * 52 = £23,901.80. # https://www.legislation.gov.uk/uksi/2023/316/article/30/made - # PC income = £20,000 - £2,082.10 deductions - £1,040 disregard = - # £16,877.90; the award is £23,901.80 - £16,877.90 = £7,023.90. - assert results[0] == pytest.approx((2_082.10, 16_877.90, 7_023.90), abs=0.01) + # PC income = £20,000 - £2,334.10 deductions - £1,040 disregard = + # £16,625.90; the award is £23,901.80 - £16,625.90 = £7,275.90. + assert results[0] == pytest.approx((2_334.10, 16_625.90, 7_275.90), abs=0.01) assert results[1] == pytest.approx(results[0], abs=0.01) diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py index 5ea41141c3..8d2dfdcbd7 100644 --- a/policyengine_uk/tests/test_property_income_properties.py +++ b/policyengine_uk/tests/test_property_income_properties.py @@ -348,7 +348,7 @@ def set_couple_inputs(sim, drawn, uses_allowance=None): @SETTINGS @given(couples) -def test_no_one_lowers_their_tax_by_switching_route_alone(couple_sim, drawn): +def test_no_one_lowers_their_benunit_tax_by_switching_route_alone(couple_sim, drawn): set_couple_inputs(couple_sim, drawn) routes = couple_sim.calculate("uses_property_allowance", YEAR) income_tax = couple_sim.calculate("income_tax", YEAR).astype(float) @@ -364,8 +364,59 @@ def test_no_one_lowers_their_tax_by_switching_route_alone(couple_sim, drawn): continue set_couple_inputs(couple_sim, drawn, routes ^ switching) switched_tax = couple_sim.calculate("income_tax", YEAR).astype(float) - margin = tolerance(income_tax, switched_tax) - assert (switched_tax[switching] >= (income_tax - margin)[switching]).all() + # The production choice minimises benefit-unit tax: a person's + # route can move the spouse's Marriage Allowance reduction. + benunit_tax = income_tax.reshape(COUPLES, 2).sum(axis=1) + switched_benunit_tax = switched_tax.reshape(COUPLES, 2).sum(axis=1) + affected = switching.reshape(COUPLES, 2).any(axis=1) + margin = tolerance(benunit_tax, switched_benunit_tax) + assert ( + switched_benunit_tax[affected] >= (benunit_tax - margin)[affected] + ).all() + + +def test_couple_property_choice_can_raise_own_tax_to_lower_joint_tax(): + # The exact first failing CI couple: the second spouse's own-tax-only + # switch is cheaper for that spouse, but loses the partner's Marriage + # Allowance reduction. The model explicitly chooses combined tax. + members = [ + dict( + employment_income=20_000.0, + property_income=9619.550558962575, + property_rental_income=9619.550558962575 + 3.453341175027317, + property_finance_costs=996.5466588249727, + ), + dict( + employment_income=12424.10181963531, + property_income=248.0230841751589, + property_rental_income=248.0230841751589, + property_finance_costs=51.0, + ), + ] + people = { + f"person_{i}": { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + **{name: {YEAR: value} for name, value in values.items()}, + } + for i, values in enumerate(members) + } + situation = { + "people": people, + "benunits": {"couple": {"members": list(people), "is_married": {YEAR: True}}}, + "households": {"household": {"members": list(people)}}, + } + simulation = Simulation(situation=situation) + routes = simulation.calculate("uses_property_allowance", YEAR).copy() + baseline = simulation.calculate("income_tax", YEAR).astype(float) + assert routes[1] + alternative = Simulation(situation=situation) + alternative.set_input( + "uses_property_allowance", YEAR, routes ^ np.array([False, True]) + ) + switched = alternative.calculate("income_tax", YEAR).astype(float) + assert switched[1] < baseline[1] - 1 + assert switched.sum() > baseline.sum() + 1 # Pensioner households, while the winter fuel payment charge has effect: a @@ -374,8 +425,8 @@ def test_no_one_lowers_their_tax_by_switching_route_alone(couple_sim, drawn): # Pension Credit decides whether the partner is entitled to a payment, which # moves the shared payment, and so the charge, of anyone else in the # household. Each landlord with finance costs to relieve must still choose -# the route that gives them the lower income tax including the charge, with -# every other route held fixed. Landlords without finance costs take the +# the route that gives their benefit unit the lower income tax including the +# charge, with every other route held fixed. Landlords without finance costs take the # allowance without comparing (uses_property_allowance); review r7's lone # pensioner below shows that can cost more, an intended limit pinned by its # own test. @@ -484,6 +535,12 @@ def person(age): ) +def benunit_totals(sim, values): + """Each person's benefit unit total of a person-level array.""" + totals = sim.map_result(values, "person", "benunit") + return np.asarray(sim.map_result(totals, "benunit", "person"), dtype=float) + + def set_pensioner_inputs(sim, drawn, uses_allowance=None): ages, incomes, claims_pc = [], [], [] for claimant_age, partner_age, lodger_age, *landlords, claims in drawn: @@ -535,12 +592,18 @@ def test_no_pensioner_lowers_their_tax_and_charge_by_switching_alone( continue set_pensioner_inputs(sim, drawn, routes ^ switching) switched_tax = sim.calculate("income_tax", PENSIONER_YEAR).astype(float) - margin = tolerance(income_tax, switched_tax) - assert (switched_tax[switching] >= (income_tax - margin)[switching]).all(), ( + # The choice weighs the benefit unit's tax (the claimant's and the + # partner's together; the lodger is a benefit unit alone). + benunit_tax = benunit_totals(sim, income_tax) + switched_benunit_tax = benunit_totals(sim, switched_tax) + margin = tolerance(benunit_tax, switched_benunit_tax) + assert ( + switched_benunit_tax[switching] >= (benunit_tax - margin)[switching] + ).all(), ( ROLES[k], np.flatnonzero(switching), - income_tax[switching], - switched_tax[switching], + benunit_tax[switching], + switched_benunit_tax[switching], ) diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 7586378f28..40bba3e3c5 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -839,3 +839,99 @@ def test_dated_reform_after_the_data_year_is_unchanged_by_the_baseline_split(): assert calculate(sim, "state_pension", 2027)[0] == approx( expected["flat"] + expected["additional"] ) + + +@pytest.mark.parametrize("composition", ["singletons", "married_pairs"]) +@pytest.mark.parametrize("cap", [2.261527994036541e-210, 0.0], ids=["tiny", "zero"]) +def test_tiny_new_cap_constructor_preserves_public_components(cap, composition): + """A finite cap must stay finite through public counterfactual construction. + + These are the original finite records from the rate-reform property failure, + also paired into lawful married benefit units. All are below State Pension + age; changing the flat rate must leave the seven-year add-on oracle intact. + """ + people = [ + {"age": 55, "weekly": 200.0, "male": True, "months": 1.5848689724912486e-117}, + {"age": 58, "weekly": 0.0, "male": True, "months": 0.0}, + {"age": 55, "weekly": 0.0, "male": False, "months": 0.0}, + {"age": 55, "weekly": 0.0, "male": False, "months": 0.0}, + ] + + def dataset(): + person = person_table(people) + ids = np.arange(len(people)) + if composition == "married_pairs": + person["person_benunit_id"] = [0, 0, 1, 1] + person["person_household_id"] = [0, 0, 1, 1] + person["marital_status"] = "MARRIED" + ids = np.arange(2) + benunit = pd.DataFrame({"benunit_id": ids}) + if composition == "married_pairs": + benunit["is_married"] = True + household = pd.DataFrame( + { + "household_id": ids, + "household_weight": np.ones(len(ids)), + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ) + return UKSingleYearDataset( + person=person, benunit=benunit, household=household, fiscal_year=DATA_YEAR + ) + + baseline = Microsimulation(dataset=dataset()) + reform = { + BSP: {"2000-01-01.2100-12-31": 0.0}, + NSP: {"2000-01-01.2100-12-31": cap}, + } + reformed = Microsimulation(dataset=dataset(), reform=reform) + for year in YEARS: + assert np.allclose( + calculate(reformed, "additional_state_pension", year), + calculate(baseline, "additional_state_pension", year), + rtol=1e-12, + atol=1e-9, + ), year + for variable in COMPONENTS + ["state_pension", "net_income"]: + assert np.all(np.isfinite(calculate(reformed, variable, year))), variable + assert np.all(calculate(reformed, "state_pension", year) == 0) + spouses = calculate(reformed, "is_marriage_allowance_spouse", year) + assert np.all(spouses == (composition == "married_pairs")) + + +@pytest.mark.parametrize("cap", [2.261527994036541e-210, 0.0], ids=["tiny", "zero"]) +def test_tiny_basic_cap_constructor_preserves_positive_add_on(cap): + """A BASIC pensioner's positive add-on survives a tiny or zero flat rate. + + The £80.50 a week above the legislated 2024 basic rate follows September + CPI, including when a finite reform cap is below float32's range. + """ + person = {"age": 90, "male": True, "months": 3, "weekly": 250} + baseline = projected_simulation([person]) + reformed = Microsimulation( + dataset=year_dataset([person], DATA_YEAR), + reform={BSP: {"2000-01-01.2100-12-31": cap}}, + ) + for year in YEARS: + additional = calculate(reformed, "additional_state_pension", year) + assert np.all(additional > 0) + assert np.allclose( + additional, + calculate(baseline, "additional_state_pension", year), + rtol=1e-12, + atol=1e-9, + ), year + assert additional[0] == approx( + (250 - LEGISLATED[(DATA_YEAR, "BASIC")]) + * WEEKS_IN_YEAR + * add_on_uprating(reformed, year) + ) + for variable in COMPONENTS + ["state_pension", "net_income"]: + assert np.all(np.isfinite(calculate(reformed, variable, year))), variable + assert calculate(reformed, "new_state_pension", year)[0] == 0 + assert calculate(reformed, "basic_state_pension", year)[0] == pytest.approx( + cap * WEEKS_IN_YEAR, abs=0.01 + ) diff --git a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py index ad32b4fee5..8322087645 100644 --- a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py +++ b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py @@ -13,10 +13,13 @@ statutory paternity pay: 1. Monotonic: raising the recipient's statutory paternity pay, with - everything else fixed, never lowers the recipient's income tax or the - family's total income tax. A Child Benefit opt-out can lower the family's - tax as income rises (the High Income Child Benefit Charge stops when the - benefit stops), for wages too, so the generated families do not opt out. + everything else fixed, never lowers the family's total income tax, nor the + recipient's while the couple's Marriage Allowance election stays the same. + A Child Benefit opt-out can lower the family's tax as income rises (the + High Income Child Benefit Charge stops when the benefit stops), for wages + too, so the generated families do not opt out. A couple can stop electing + as the recipient's income rises, and the recipient's own tax then falls + while the couple's rises (intended; ITA 2007 s. 55B(6)). 2. Bounds: a pound more of statutory paternity pay raises the family's income tax, before the winter fuel payment charge and less the High Income Child Benefit Charge, by at most a pound, for families that do not @@ -59,7 +62,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -256,6 +259,51 @@ def family_total(self, variable, i, version): return float(self.members(variable, i, version).sum()) +ELECTION = "makes_marriage_allowance_election" + +# Intended exception to invariant 1 for the recipient alone (2026-27). The +# recipient has no wages; their spouse earns £24,000 and pays 2,286. On +# £12,000 of statutory paternity pay the recipient gives up £1,260 of +# allowance, paying 20% x (12,000 - 11,310) = 138 for the spouse's 252 +# reduction. On £12,570 giving it up would cost 252 for 252, so no one +# elects: the recipient pays nothing and the couple 114 more. +STOPS_ELECTING = dict( + adults=[ + dict( + age=33, + wages=0.0, + profit=0.0, + pension_income=0.0, + savings=0.0, + dividends=0.0, + pension_contributions=0.0, + student_loan_plan="NONE", + claims_marriage_allowance=True, + ), + dict( + age=33, + wages=24_000.0, + profit=0.0, + pension_income=0.0, + savings=0.0, + dividends=0.0, + pension_contributions=0.0, + student_loan_plan="NONE", + claims_marriage_allowance=True, + ), + ], + married=True, + child_ages=[], + region="NORTH_EAST", + tenure="RENT_PRIVATELY", + rent=0.0, + would_claim_uc=False, + recipient=0, + low=12_000.0, + high=12_570.0, +) + + def claims_marriage_allowance(family): return family["married"] and any( adult["claims_marriage_allowance"] for adult in family["adults"] @@ -263,15 +311,19 @@ def claims_marriage_allowance(family): @PROPERTY_SETTINGS +@example(population=[STOPS_ELECTING]) @given(population=populations()) def test_income_tax_never_falls_as_statutory_paternity_pay_rises(population): results = Results(population) for i, family in enumerate(population): # Invariant 1. + same_election = np.array_equal( + results.members(ELECTION, i, HIGH), results.members(ELECTION, i, LOW) + ) recipient_gain = results.recipient("income_tax", i, HIGH) - results.recipient( "income_tax", i, LOW ) - assert recipient_gain >= -TOLERANCE, family + assert recipient_gain >= -TOLERANCE or not same_election, family family_gain = results.family_total( "income_tax", i, HIGH ) - results.family_total("income_tax", i, LOW) @@ -297,6 +349,14 @@ def test_income_tax_never_falls_as_statutory_paternity_pay_rises(population): assert gain <= family["high"] - family["low"] + TOLERANCE, family +def test_a_couple_can_stop_electing_as_statutory_paternity_pay_rises(): + results = Results([STOPS_ELECTING]) + assert results.members(ELECTION, 0, LOW).tolist() == [1.0, 0.0] + assert results.members(ELECTION, 0, HIGH).tolist() == [0.0, 0.0] + assert results.members("income_tax", 0, LOW).tolist() == [138, 2_034] + assert results.members("income_tax", 0, HIGH).tolist() == [0, 2_286] + + @PROPERTY_SETTINGS @given(population=populations()) def test_statutory_paternity_pay_is_treated_as_statutory_sick_pay(population): diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 64d185e41d..74d7be30db 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -26,14 +26,17 @@ Invariants 1-3 hold only while unearned income leaves the person's allowances alone, so the generated incomes keep adjusted net income below -the personal allowance taper (100,000) and no one claims Marriage Allowance -(see test_uc_state_pension_properties.py for the Marriage Allowance -deviation). Tax reductions are covered: adults born before 6 April 1935 can -have a married couple's allowance, which comes off the tax on earnings -first. Invariant 4 runs with Marriage Allowance claimed, and with everyone in -a start-up period: the minimum income floor now compares net earned income +the personal allowance taper (100,000). Tax reductions are covered: adults +born before 6 April 1935 can have a married couple's allowance, and couples +claim Marriage Allowance; both come off the tax on earnings first. Each +couple's Marriage Allowance election is held at its choice in the first run: +the tax on the gaining partner's earnings then does not depend on anyone's +unearned income, but the couple's choice to elect responds to it (see +test_uc_state_pension_properties.py). Invariant 4 runs with everyone in a +start-up period: the minimum income floor now compares net earned income with a net threshold (test_uc_minimum_income_floor_properties.py), which -moves earned income either way, so it is held off here. +moves earned income either way, so it is held off here. Its two runs elect +alike, since the formula it compares against changes no one's tax. """ import numpy as np @@ -92,7 +95,9 @@ "universal_credit_pre_benefit_cap", "uc_earned_income", ] +ELECTION = "makes_marriage_allowance_election" PERSON_VARIABLES = [ + ELECTION, "uc_individual_earned_income", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", @@ -138,11 +143,12 @@ def families(draw): ) -def situation(units, year, bump=None, earnings_only=False, marriage_allowance=False): +def situation(units, year, bump=None, earnings_only=False, election=None): """One simulation holding every family. ``bump`` is (family index, adult index, variable, amount) to add. - ``earnings_only`` drops every kind of unearned income. + ``earnings_only`` drops every kind of unearned income. ``election``, one + value per person in order, fixes who makes a Marriage Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -167,21 +173,27 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa if bump is not None and bump[:2] == (i, j) and not earnings_only: variable, amount = bump[2], bump[3] person[variable] = {year: adult.get(variable, 0.0) + amount} - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} people[name] = person names.append(name) for k, age in enumerate(unit["children"]): name = f"c{i}_{k}" people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) - benunits[f"b{i}"] = {"members": names} + benunits[f"b{i}"] = { + "members": names, + # Marriage Allowance needs a marriage or civil partnership + # (ITA 2007 s. 55C(1)(a)). + "is_married": {year: len(unit["adults"]) == 2}, + } households[f"h{i}"] = { "members": names, "rent": {year: unit["rent"]}, "tenure_type": {year: unit["tenure"]}, "region": {year: unit["region"]}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} @@ -234,7 +246,7 @@ def bumped(draw): def test_unearned_income_never_changes_earned_income(case, year): units, bump = case low = calculate(units, year) - high = calculate(units, year, bump=bump) + high = calculate(units, year, bump=bump, election=low[ELECTION]) for variable in ["uc_individual_earned_income", "uc_earned_income"]: np.testing.assert_allclose( high[variable], low[variable], atol=0.01, err_msg=f"{bump} {units}" @@ -246,7 +258,7 @@ def test_unearned_income_never_changes_earned_income(case, year): def test_uc_is_non_increasing_in_unearned_income(case, year): units, bump = case low = calculate(units, year) - high = calculate(units, year, bump=bump) + high = calculate(units, year, bump=bump, election=low[ELECTION]) for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: assert np.all(high[variable] <= low[variable] + 0.01), (variable, bump, units) @@ -258,7 +270,7 @@ def test_uc_is_non_increasing_in_unearned_income(case, year): ) def test_deductions_equal_tax_and_ni_on_earnings_alone(units, year): full = calculate(units, year) - alone = calculate(units, year, earnings_only=True) + alone = calculate(units, year, earnings_only=True, election=full[ELECTION]) adult = full["is_adult_person"] # Income tax in respect of earnings (UC Regs 2013 reg 55(5)): the # liability before the winter fuel payment charge, which recovers a @@ -370,8 +382,8 @@ def test_fix_only_removes_deductions(units, year): ) for unit in units ] - after = calculate(units, year, marriage_allowance=True) - before = calculate(units, year, scenario=BEFORE_1942, marriage_allowance=True) + after = calculate(units, year) + before = calculate(units, year, scenario=BEFORE_1942) assert np.all(after["uc_earned_income"] >= before["uc_earned_income"] - 0.01), units for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: assert np.all(after[variable] <= before[variable] + 0.01), (variable, units) diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index 2d0b1b2c80..7d4a330014 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -24,14 +24,14 @@ the award, before or after the benefit cap, and never lowers tariff income. -Invariant 2's award clause runs with Marriage Allowance switched off. The -model books it on the recipient as the transferor's unused personal allowance -(PolicyEngine/policyengine-uk#1947), so capital income that uses up one -partner's allowance raises the other partner's tax on earnings. The clause -also compares only families whose personal allowances the income leaves -unchanged: above 100,000 of adjusted net income the allowance tapers (ITA 2007 -s. 35), which raises the tax a person pays on their earnings and so changes -the deduction from them (see test_uc_earnings_deductions_properties.py). +Invariant 2's award clause holds each couple's Marriage Allowance election +at its choice in the base run: capital income can change whether an election +is allowed or lowers the couple's tax, and so the gaining partner's tax on +earnings. The clause also compares only families whose personal +allowances the income leaves unchanged: above 100,000 of adjusted net income +the allowance tapers (ITA 2007 s. 35), which raises the tax a person pays on +their earnings and so changes the deduction from them (see +test_uc_earnings_deductions_properties.py). The award after the benefit cap is compared only where the cap exemption is also unchanged. The cap's earnings exception now reads each claimant's Universal Credit earned income (uc_individual_earned_income_before_mif), which @@ -91,6 +91,7 @@ "is_uc_eligible", "is_uc_benefit_cap_exempt", ] +ELECTION = "makes_marriage_allowance_election" @st.composite @@ -114,15 +115,13 @@ def families(draw): ) -def situation( - units, year, income_scale=1.0, capital_bump=None, marriage_allowance=True -): +def situation(units, year, income_scale=1.0, capital_bump=None, election=None): """Build one simulation holding every family. ``income_scale`` multiplies each family's interest, dividends and rent; ``capital_bump`` is an optional (source, amount) added to every family's - capital. With ``marriage_allowance=False`` no one claims Marriage - Allowance. + capital. ``election``, one value per person in order, fixes who makes a + Marriage Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -133,8 +132,6 @@ def situation( # claimant-or-partner presumption (a member under 20 and much # younger is the head's child) does not apply. Children get False. person = {"age": {year: age}, "is_claimant_or_partner": {year: True}} - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} if j == 0: person["employment_income"] = {year: unit["earnings"]} person["private_pension_income"] = { @@ -166,6 +163,9 @@ def situation( "main_residence_value": {year: unit["main_residence_value"]}, **{source: {year: value} for source, value in stocks.items()}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} @@ -179,6 +179,7 @@ def listed_sources(sim, year): def calculate(units, year, **kwargs): sim = Simulation(situation=situation(units, year, **kwargs)) values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + values[ELECTION] = np.asarray(sim.calculate(ELECTION, year)) values["personal_allowances"] = np.asarray( sim.calculate("personal_allowance", year, map_to="benunit") ) @@ -248,8 +249,8 @@ def test_interest_dividends_and_rent_do_not_enter_the_means_test(units, scale, y year=st.sampled_from(YEARS), ) def test_interest_dividends_and_rent_leave_the_award_unchanged(units, scale, year): - base = calculate(units, year, marriage_allowance=False) - scaled = calculate(units, year, income_scale=scale, marriage_allowance=False) + base = calculate(units, year) + scaled = calculate(units, year, income_scale=scale, election=base[ELECTION]) # Only one adult receives the capital income, so the unit's summed # personal allowances are unchanged exactly when that adult's is. unchanged = np.isclose( @@ -353,8 +354,8 @@ def test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception(): }, recipient=0, ) - with_dividends = calculate([unit], 2026, marriage_allowance=False) - without = calculate([unit], 2026, income_scale=0.0, marriage_allowance=False) + with_dividends = calculate([unit], 2026) + without = calculate([unit], 2026, income_scale=0.0) assert with_dividends["uc_earned_income"][0] == pytest.approx( without["uc_earned_income"][0], abs=0.01 ) diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index c8915c65dc..11f81d1cae 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -25,12 +25,15 @@ the pre-cap award by min(x, award), whatever the partner earns. Any tariff income from capital remains the same in all three cases. -Marriage Allowance is switched off for invariants 2, 3 and 4. The model gives -the recipient min(partner's unused personal allowance, 10% of the personal -allowance) instead of the fixed transferable amount in ITA 2007 s. 55B(4)-(6), -so State Pension or property income that uses up the pensioner's unused -allowance raises the earning partner's tax on earnings. The strict xfail at -the end pins that deviation (PolicyEngine/policyengine-uk#1947). +Marriage Allowance is claimed throughout. Once a couple has elected, the +gaining partner's reduction is fixed (ITA 2007 s. 55B(1), (4)-(6)), so the +pensioner's income cannot move the earning partner's tax on earnings. The +couple's election itself responds to the pensioner's income: the couple +stops electing once it no longer lowers their tax (from 12,570 of State +Pension in 2026-27), which raises the earner's tax and so the award. So the +invariants that compare runs hold each couple's election at its choice in +the first run; test_couple_stops_electing_when_it_no_longer_saves_tax pins +the change in election (PolicyEngine/policyengine-uk#1947). """ import numpy as np @@ -73,6 +76,7 @@ "uc_income_reduction", "uc_maximum_amount", ] +ELECTION = "makes_marriage_allowance_election" @st.composite @@ -98,7 +102,7 @@ def situation( year, income_variable="state_pension", pension_bump=0.0, - marriage_allowance=True, + election=None, ): """Build one simulation holding every family. @@ -106,8 +110,8 @@ def situation( ``pension_bump``) under ``income_variable``; when that is not ``state_pension``, their State Pension is set to zero so the same amount arrives as the other income instead. A working-age partner receives the - family's earnings. With ``marriage_allowance=False`` no one claims - Marriage Allowance. + family's earnings. ``election``, one value per person in order, fixes + who makes a Marriage Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -124,8 +128,6 @@ def situation( "state_pension": {year: 0.0}, "is_claimant_or_partner": {year: True}, } - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} if j == 0: amount = unit["state_pension"] + pension_bump person[income_variable] = {year: amount} @@ -137,19 +139,27 @@ def situation( name = f"c{i}_{k}" people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) - benunits[f"b{i}"] = {"members": names} + benunits[f"b{i}"] = { + "members": names, + # Marriage Allowance needs a marriage or civil partnership + # (ITA 2007 s. 55C(1)(a)). + "is_married": {year: len(unit["ages"]) == 2}, + } households[f"h{i}"] = { "members": names, "rent": {year: unit["rent"]}, "tenure_type": {year: unit["tenure"]}, "savings": {year: unit["savings"]}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} def calculate(units, year, **kwargs): sim = Simulation(situation=situation(units, year, **kwargs)) - return {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + return {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES + [ELECTION]} def assert_same(a, b, message): @@ -167,7 +177,7 @@ def assert_same(a, b, message): ) def test_uc_is_non_increasing_in_state_pension(units, bump, year): low = calculate(units, year) - high = calculate(units, year, pension_bump=bump) + high = calculate(units, year, pension_bump=bump, election=low[ELECTION]) for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: assert np.all(high[variable] <= low[variable] + 0.01), (variable, units) # State Pension changes nothing in the maximum amount, and all of it is @@ -213,9 +223,10 @@ def test_uc_is_non_increasing_in_state_pension(units, bump, year): ) def test_uc_falls_pound_for_pound_in_state_pension(units, bump, year): # Earnings in the family change nothing: tax on State Pension is never - # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3). - low = calculate(units, year, marriage_allowance=False) - high = calculate(units, year, pension_bump=bump, marriage_allowance=False) + # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3), and + # an election already made fixes the earner's Marriage Allowance. + low = calculate(units, year) + high = calculate(units, year, pension_bump=bump, election=low[ELECTION]) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -259,9 +270,11 @@ def test_state_pension_counts_like_private_pension(units, year): ) def test_property_income_is_excluded_while_state_pension_counts(units, year): # Neither income's tax comes off the partner's earnings (reg. 55(5)(b)), - # so this holds with earnings in the family too. - kwargs = dict(marriage_allowance=False) - pension = calculate(units, year, **kwargs) + # so this holds with earnings in the family too. The pensioner's income + # can change whether the couple elects, so hold the election at the + # State Pension run's choice. + pension = calculate(units, year) + kwargs = dict(election=pension[ELECTION]) property_income = calculate( units, year, income_variable="property_income", **kwargs ) @@ -287,8 +300,9 @@ def test_property_income_is_excluded_while_state_pension_counts(units, year): def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): # 2026: pensioner aged 70 with State Pension 16,000 pays income tax of # (16,000 - 12,570) x 20% = 686. The partner aged 45 earns 13,000 and pays - # income tax of 86 and NI of 34.40 (above the personal allowance, so no - # marriage allowance). Council rent 20,000. + # income tax of 86 and NI of 34.40. Either spouse electing for Marriage + # Allowance would cost them 252 and save the other 252, so the couple + # does not elect. Council rent 20,000. # Earned income = 13,000 - 86 - 34.40 = 12,879.60 (reg. 55(5)(b)). # Maximum amount = 12 x 666.97 + 20,000 = 28,003.64. # UC = 28,003.64 - (0.55 x 12,879.60 + 16,000) = 4,919.86. @@ -306,14 +320,6 @@ def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): assert values["universal_credit"][0] == pytest.approx(4_919.86, abs=0.01) -@pytest.mark.xfail( - strict=True, - reason=( - "PolicyEngine/policyengine-uk#1947: Marriage Allowance is booked on " - "the recipient as the transferor's unused personal allowance, so the " - "transferor's State Pension raises the recipient's tax on earnings" - ), -) def test_state_pension_does_not_change_partners_marriage_allowance(): # 2026: pensioner aged 70, partner aged 45 earning 20,000, council rent # 20,000. Once the pensioner elects, ITA 2007 s. 55B gives the partner a @@ -322,7 +328,8 @@ def test_state_pension_does_not_change_partners_marriage_allowance(): # keeps them within the basic rate, so they can elect (s. 55C(1)(c)). # Partner's tax on earnings = (20,000 - 12,570) x 20% - 252 = 1,234 and # NI (20,000 - 12,569.96) x 8% = 594.40, so earned income = 18,171.60 - # at any State Pension up to the pensioner's election limit. + # at any State Pension while the election saves the couple tax (below + # 12,570). # UC = 8,003.64 + 20,000 - (0.55 x 18,171.60 + 12,000) = 6,009.26. unit = dict( ages=[70, 45], @@ -336,3 +343,34 @@ def test_state_pension_does_not_change_partners_marriage_allowance(): values = calculate([unit], 2026) assert values["uc_earned_income"][0] == pytest.approx(18_171.60, abs=0.01) assert values["universal_credit"][0] == pytest.approx(6_009.26, abs=0.01) + + +def test_couple_stops_electing_when_it_no_longer_saves_tax(): + # 2026, the couple above with State Pension of 12,500 and 12,600. At + # 12,500 electing costs the pensioner (12,500 - 11,310) x 20% = 238 and + # saves the partner 252, so the couple elects and the partner's earned + # income is 18,171.60 as above: + # UC = 28,003.64 - (0.55 x 18,171.60 + 12,500) = 5,509.26. + # At 12,600 it would cost 252, the same as it saves, so the couple does not + # elect. The partner's tax on earnings is 1,486, earned income + # 20,000 - 1,486 - 594.40 = 17,919.60, and + # UC = 28,003.64 - (0.55 x 17,919.60 + 12,600) = 5,547.86. + # The election weighs only the couple's income tax, not their UC, so 100 + # more State Pension raises UC by 38.60 here. + unit = dict( + ages=[70, 45], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=20_000.0, + savings=0.0, + earnings=20_000.0, + state_pension=12_500.0, + ) + low = calculate([unit], 2026) + high = calculate([unit], 2026, pension_bump=100.0) + assert low[ELECTION].tolist() == [True, False] + assert high[ELECTION].tolist() == [False, False] + assert low["uc_earned_income"][0] == pytest.approx(18_171.60, abs=0.01) + assert high["uc_earned_income"][0] == pytest.approx(17_919.60, abs=0.01) + assert low["universal_credit"][0] == pytest.approx(5_509.26, abs=0.01) + assert high["universal_credit"][0] == pytest.approx(5_547.86, abs=0.01) diff --git a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py index 78a59e33a0..ba67888d58 100644 --- a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py @@ -26,13 +26,17 @@ For carer support payment both families keep the carer's caring hours, so both get the carer element. -Marriage Allowance is switched off for invariants 2 and 3, and for invariant -1 for carer support payment. The model books it on the recipient as the -transferor's unused personal allowance (PolicyEngine/policyengine-uk#1947), -so a taxable benefit that uses up the first adult's allowance raises the -earning partner's tax on earnings. Invariants 2 and 3 compare the award before the benefit cap because -contributory ESA and industrial injuries benefit trigger benefit cap -exemptions and ESA counts towards the cap, which private pension does not. +Marriage Allowance is claimed throughout. Once a couple has elected, the +gaining partner's reduction is fixed (ITA 2007 s. 55B(1), (4)-(6)), so the +first adult's benefit cannot move the earning partner's tax on earnings. The +couple's election itself can respond to the benefit, since a taxable benefit +changes whether electing lowers the couple's tax (see +test_uc_state_pension_properties.py). So every invariant that compares runs +holds each couple's election at its choice in the first run +(PolicyEngine/policyengine-uk#1947). Invariants 2 and 3 compare the award +before the benefit cap because contributory ESA and industrial injuries +benefit trigger benefit cap exemptions and ESA counts towards the cap, which +private pension does not. """ import numpy as np @@ -84,6 +88,7 @@ "uc_income_reduction", "uc_maximum_amount", ] +ELECTION = "makes_marriage_allowance_election" @st.composite @@ -100,17 +105,16 @@ def families(draw): ) -def situation( - units, year, income_variable, bump=0.0, carer=False, marriage_allowance=True -): +def situation(units, year, income_variable, bump=0.0, carer=False, election=None): """Build one simulation holding every family. The first adult receives the family's ``amount`` (plus ``bump``) under ``income_variable``; a partner receives the family's earnings. With ``carer``, the first adult cares for 40 hours a week in Scotland and has carer support payment set explicitly (zero unless it is the income - variable), so the carer element applies whatever the income. With - ``marriage_allowance=False`` no one claims Marriage Allowance. + variable), so the carer element applies whatever the income. + ``election``, one value per person in order, fixes who makes a Marriage + Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -122,8 +126,6 @@ def situation( # head's child) does not apply. Setting it for anyone makes it an # input for everyone, so children get False below. person = {"age": {year: age}, "is_claimant_or_partner": {year: True}} - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} if j == 0: if carer: person["care_hours"] = {year: 40} @@ -145,12 +147,16 @@ def situation( "savings": {year: unit["savings"]}, "country": {year: "SCOTLAND" if carer else "ENGLAND"}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} def calculate(units, year, income_variable, **kwargs): sim = Simulation(situation=situation(units, year, income_variable, **kwargs)) values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + values[ELECTION] = np.asarray(sim.calculate(ELECTION, year)) counted = np.asarray(sim.calculate("uc_unearned_carer_support_payment", year)) values["counted_csp"] = np.asarray( sim.map_result(counted, "person", "benunit", how="sum") @@ -203,7 +209,7 @@ def assert_monotone(low, high, increase, units): ) def test_uc_is_non_increasing_in_each_benefit(units, benefit, bump, year): low = calculate(units, year, benefit) - high = calculate(units, year, benefit, bump=bump) + high = calculate(units, year, benefit, bump=bump, election=low[ELECTION]) assert_monotone(low, high, bump, units) @@ -214,9 +220,15 @@ def test_uc_is_non_increasing_in_each_benefit(units, benefit, bump, year): year=st.sampled_from(CSP_YEARS), ) def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): - kwargs = dict(carer=True, marriage_allowance=False) - low = calculate(units, year, "carer_support_payment", **kwargs) - high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) + low = calculate(units, year, "carer_support_payment", carer=True) + high = calculate( + units, + year, + "carer_support_payment", + bump=bump, + carer=True, + election=low[ELECTION], + ) increase = high["counted_csp"] - low["counted_csp"] assert np.all(increase >= -0.01), units assert_monotone(low, high, increase, units) @@ -230,8 +242,8 @@ def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): year=st.sampled_from(YEARS), ) def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): - low = calculate(units, year, benefit, marriage_allowance=False) - high = calculate(units, year, benefit, bump=bump, marriage_allowance=False) + low = calculate(units, year, benefit) + high = calculate(units, year, benefit, bump=bump, election=low[ELECTION]) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -248,9 +260,15 @@ def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): year=st.sampled_from(CSP_YEARS), ) def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, year): - kwargs = dict(carer=True, marriage_allowance=False) - low = calculate(units, year, "carer_support_payment", **kwargs) - high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) + low = calculate(units, year, "carer_support_payment", carer=True) + high = calculate( + units, + year, + "carer_support_payment", + bump=bump, + carer=True, + election=low[ELECTION], + ) award = low["universal_credit_pre_benefit_cap"] increase = high["counted_csp"] - low["counted_csp"] np.testing.assert_allclose( @@ -268,9 +286,13 @@ def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, year=st.sampled_from(YEARS), ) def test_each_benefit_counts_like_private_pension(units, benefit, year): + with_benefit = calculate(units, year, benefit) + with_pension = calculate( + units, year, "private_pension_income", election=with_benefit[ELECTION] + ) assert_same( - calculate(units, year, benefit, marriage_allowance=False), - calculate(units, year, "private_pension_income", marriage_allowance=False), + with_benefit, + with_pension, [v for v in UC_VARIABLES if v != "universal_credit"], str(units), ) @@ -282,12 +304,17 @@ def test_each_benefit_counts_like_private_pension(units, benefit, year): year=st.sampled_from(CSP_YEARS), ) def test_carer_support_payment_counts_like_capped_private_pension(units, year): - kwargs = dict(carer=True, marriage_allowance=False) - with_csp = calculate(units, year, "carer_support_payment", **kwargs) + with_csp = calculate(units, year, "carer_support_payment", carer=True) # The same family with the counted amount of Carer Support Payment # received as private pension instead. counted = [dict(unit, amount=c) for unit, c in zip(units, with_csp["counted_csp"])] - with_pension = calculate(counted, year, "private_pension_income", **kwargs) + with_pension = calculate( + counted, + year, + "private_pension_income", + carer=True, + election=with_csp[ELECTION], + ) assert_same( with_csp, with_pension, diff --git a/policyengine_uk/utils/aggregate_impact/measure.py b/policyengine_uk/utils/aggregate_impact/measure.py index 192a512422..fd59ac060a 100644 --- a/policyengine_uk/utils/aggregate_impact/measure.py +++ b/policyengine_uk/utils/aggregate_impact/measure.py @@ -1205,6 +1205,47 @@ def publishable_guard(guard: dict) -> dict: return out +def component_guard_diagnostic(row: Mapping, partition: Partition) -> dict: + """A failed component's public identifiers and suppressed aggregate counts. + + This is an assertion message, not a change to the guard's verdict. Only + plain strings from the trusted partition and fixed enums may be printed; + values from unrecognised types are never coerced or represented. + """ + components = set(partition.component_variables()) + totals = set(partition.totals) + heads = {head.variable for head in partition.heads} | totals + + def public_identifier(value, allowed): + return value if type(value) is str and value in allowed else "unknown" + + def public_count(value): + # counted_once creates built-in integer counts; reject booleans, + # subclasses and other objects without invoking their conversions. + if type(value) is not int or value < 0 or small(value): + return None + return value + + row = row if type(row) is dict else {} + problems = row.get("problems") + problems = problems if type(problems) in (list, tuple) else () + kinds = {"own_head", "contains", "moves", "rises", "model_total"} + return { + "variable": public_identifier(row.get("variable"), components), + "status": public_identifier(row.get("status"), {"pass", "fail", "skipped"}), + "problems": [ + { + "total": public_identifier(problem.get("total"), totals), + "head": public_identifier(problem.get("head"), heads), + "kind": public_identifier(problem.get("kind"), kinds), + "households": public_count(problem.get("households")), + } + for problem in problems + if type(problem) is dict + ], + } + + def _finite_metrics_check(rec: Recorder): bad = [ f"{m['id']} {fiscal_year_label(int(year))}" diff --git a/policyengine_uk/variables/gov/dwp/basic_state_pension.py b/policyengine_uk/variables/gov/dwp/basic_state_pension.py index 48ac56fb13..0f3a5a7143 100644 --- a/policyengine_uk/variables/gov/dwp/basic_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/basic_state_pension.py @@ -21,7 +21,10 @@ def formula(person, period, parameters): else: data_year = period.start.year - reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + reported = ( + person("state_pension_reported", data_year).astype(np.float64) + / WEEKS_IN_YEAR + ) pension_type = person("state_pension_type", period) # state_pension_reported was observed under the legislated rates, so # split it at the baseline's data-year rate, not the reformed one. @@ -36,7 +39,7 @@ def formula(person, period, parameters): if baseline is not None else parameters ).gov.dwp.state_pension - max_sp_data_year = legislated.basic_state_pension.amount(data_year) + max_sp_data_year = np.float64(legislated.basic_state_pension.amount(data_year)) max_sp_period = parameters.gov.dwp.state_pension.basic_state_pension.amount( period ) diff --git a/policyengine_uk/variables/gov/dwp/new_state_pension.py b/policyengine_uk/variables/gov/dwp/new_state_pension.py index 69c9a3bfce..a68da8a353 100644 --- a/policyengine_uk/variables/gov/dwp/new_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/new_state_pension.py @@ -23,7 +23,10 @@ def formula(person, period, parameters): pension_type = person("state_pension_type", period) eligible = pension_type == pension_type.possible_values.NEW - reported_weekly = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + reported_weekly = ( + person("state_pension_reported", data_year).astype(np.float64) + / WEEKS_IN_YEAR + ) # state_pension_reported was observed under the legislated rates, so # split it at the baseline's data-year rate, not the reformed one. # Otherwise a reform that also sets the data year's rate (an undated @@ -37,7 +40,7 @@ def formula(person, period, parameters): if baseline is not None else parameters ).gov.dwp.state_pension - max_new_data_year = legislated.new_state_pension.amount(data_year) + max_new_data_year = np.float64(legislated.new_state_pension.amount(data_year)) max_new_period = parameters.gov.dwp.state_pension.new_state_pension.amount( period ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py index 8ac7b852c4..02b5d0ec2e 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -42,9 +42,13 @@ def formula(person, period, parameters): scottish_basic_rate, rates.uk.rates[0], ) - # Chapter 2 (personal and blind person's allowances) and Chapter 3A - # (marriage allowance, which the model deducts from income) relieve - # income; Chapter 3 (married couple's allowance) reduces the tax. + # Chapter 2 (personal and blind person's allowances) relieves income; + # the personal allowance is already cut by any Marriage Allowance the + # person gives up (Chapter 3A, s. 55B(6)). The Marriage Allowance the + # person receives is a Chapter 3A tax reduction at the basic rate on + # that amount (s. 55B(1), (3)), the same as relieving the amount here + # at the basic rate. Chapter 3 (married couple's allowance) reduces + # the tax. reliefs = add( person, period, diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py new file mode 100644 index 0000000000..f4e09d52eb --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class is_marriage_allowance_spouse(Variable): + value_type = bool + entity = Person + label = "Spouse or civil partner for Marriage Allowance" + documentation = ( + "Whether this person is one of the married couple or civil partners " + "in their benefit unit, who can make or gain from a Marriage " + "Allowance election: the claimant and partner of a married benefit " + "unit, never a child in it. The Act needs only a marriage or civil " + "partnership, not living together, but a spouse outside the benefit " + "unit is not modelled." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55C(1)(a)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ) + + def formula(person, period, parameters): + status = person("marital_status", period) + spouse = (status == status.possible_values.MARRIED) & person( + "is_claimant_or_partner", period + ) + return spouse & (person.benunit.sum(spouse) == 2) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py new file mode 100644 index 0000000000..43ea41417e --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -0,0 +1,216 @@ +from policyengine_uk.model_api import * + +# The election is weighed on income tax before the winter fuel payment charge. +# The charge is Step 7 tax on a person whose own total income (ITA 2007 s.23 +# Step 1) is over the threshold, which the election does not change. So it can +# differ between the two positions only through the benefits that the charge +# and the payment read (ITEPA 2003 s.681I(4)), and the election does not weigh +# effects on benefits. Leaving the charge out keeps benefits out of these +# branches. +COMPARED_TAX = "income_tax_before_winter_fuel_payment_charge" +BRANCH_OUTPUTS = [ + COMPARED_TAX, + "personal_allowance", + "marriage_allowance_tax_reduction_limit", + "meets_marriage_allowance_income_conditions", +] +# Inputs to income tax that do not depend on the election. They are resolved +# before branching and held at the simulation's values in both branches, so +# the result does not depend on which variable a caller asks for first, and a +# choice made in branches upstream of them (a reform's split of net income, or +# a labour supply response in employment income) is not made again for the +# counterfactual. Net income is held as well as adjusted net income: a branch +# holding only adjusted net income would read it as a supplied input and +# rebuild net income from it (net_income_from_adjusted_net_income). +INDEPENDENT_OF_ELECTION = [ + "age", + "employment_income", + "net_income", + "adjusted_net_income", + "gift_aid_grossed_up", + "gift_aid", + "blind_persons_allowance", + "covenanted_payments", + "charitable_investment_gifts", + "other_deductions", + "pension_contributions_relief", + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "pays_scottish_income_tax", + "CB_HITC", + "other_tax_credits", + "married_couples_allowance", +] + + +def tax_position(simulation, name, period, relinquished, held): + """Each person's tax with no election in force and the given allowance cut. + + The branch holds the election off, so no one gains a reduction, and cuts + each person's personal allowance by ``relinquished`` (ITA 2007 s. 55B(6)). + It also holds each variable in ``held`` at the given values, the + simulation's own: the inputs to income tax the election does not change, + and the property allowance route. From policyengine-core 3.33, an input + set on a branch drops what the branch holds that may have been calculated + from the value it replaces. A choice the simulation made in branches of + its own before this election (the property allowance route, say) was + weighed with an election in force there, so without these inputs the + branch would make that choice again for the counterfactual it represents. + The branch shares the simulation's cached arrays until it writes, and is + dropped afterwards so a later period starts from a fresh branch. + """ + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + # A supplied tax is authoritative in the real simulation, but these + # private counterfactuals must compare formula tax at each allowance + # cut. Deletion only changes the branch's private storage. + branch.delete_arrays(COMPARED_TAX, period) + branch.set_input( + "makes_marriage_allowance_election", + period, + np.zeros(len(relinquished), dtype=bool), + ) + branch.set_input("marriage_allowance_relinquished", period, relinquished) + for variable, values in held.items(): + branch.set_input(variable, period, values) + population = branch.populations["person"] + return {variable: population(variable, period) for variable in BRANCH_OUTPUTS} + finally: + del simulation.branches[name] + release(branch) + + +def release(branch): + """Free the arrays a deleted branch holds. + + policyengine-core records each new branch in every calculation then + under way, which keeps it until they finish. Without this, both branches + and their copies of the simulation's arrays would last until the + calculation that asked for the election (household net income, say) + ended. The storage is cleared directly: deleting values through the + branch would record a change in each of those calculations, though + nothing reads the branch again. + """ + for population in branch.populations.values(): + population._holders.clear() + branch._fast_cache = {} + + +class makes_marriage_allowance_election(Variable): + value_type = bool + entity = Person + label = "Makes a Marriage Allowance election" + documentation = ( + "Whether this person elects to give up part of their personal " + "allowance so that their spouse or civil partner gets the Marriage " + "Allowance tax reduction. The couple elects only when the election " + "is allowed and lowers their combined income tax before the winter " + "fuel payment charge, in whichever direction lowers it more, and only " + "if the spouse who would gain has " + "would_claim_marriage_allowance true. Set as an input, an election " + "is taken to be one the other spouse is entitled to gain from: the " + "conditions are checked only when the model makes the choice." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55B", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 55C", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ), + dict( + title="Income Tax Act 2007 s. 55E", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55E", + ), + ] + + def formula(person, period, parameters): + held = { + variable: person(variable, period) for variable in INDEPENDENT_OF_ELECTION + } + spouse = person("is_marriage_allowance_spouse", period) + transferable = person("marriage_allowance_transferable_amount", period) + # The property allowance route (ITTOIA 2005 s. 783BL) is settled + # first, each route weighed with the election it would bring. The + # election is then weighed under that route. + held["uses_property_allowance"] = person("uses_property_allowance", period) + simulation = person.simulation + without = tax_position( + simulation, + "marriage_allowance_without_election", + period, + np.zeros_like(transferable), + held, + ) + relinquishing = tax_position( + simulation, + "marriage_allowance_relinquishing", + period, + spouse * transferable, + held, + ) + + def partner(values): + # The other spouse's value, for each spouse. + return person.benunit.sum(spouse * values) - spouse * values + + # If this person elects, the other spouse's tax falls by the + # appropriate percentage of this person's transferable amount, capped + # at the tax they have left (s. 55B(1), (3); s. 29(2)), and this + # person pays tax on the allowance they give up. + rate = person("marriage_allowance_appropriate_percentage", period) + partner_reduction = min_( + partner(rate) * transferable, + partner(without["marriage_allowance_tax_reduction_limit"]), + ) + extra_tax = relinquishing[COMPARED_TAX] - without[COMPARED_TAX] + # Tax is computed to the penny; smaller differences are rounding. + saving = np.round(partner_reduction - extra_tax, 2) + married_couples_allowance_claimed = person.benunit.any( + spouse & (person("married_couples_allowance", period) > 0) + ) + allowed = ( + spouse + # s. 55B(2)(d): neither spouse claims married couple's allowance. + & ~married_couples_allowance_claimed + # s. 55C(1)(b): the electing spouse is entitled to an allowance. + & (without["personal_allowance"] > 0) + # s. 55C(1)(c), (ca): only basic rates once the allowance is cut. + & relinquishing["meets_marriage_allowance_income_conditions"] + # s. 55B(2)(b), (ba): the gaining party pays only basic rates. + & (partner(without["meets_marriage_allowance_income_conditions"]) > 0) + ) + # Elect only to lower the couple's tax. Only one spouse elects. s. 55E + # allows each of them one election and one reduction and does not + # bar elections both ways, which would lower some couples' tax + # further (where giving up allowance costs less than the basic rate, + # as on dividends or at the Scottish starter rate), but HMRC's + # guidance describes a single transfer from the lower earner. + saving_if_allowed = where(allowed & (saving > 0), saving, 0) + other_saving = partner(saving_if_allowed) + # On a tie the spouse with the lower adjusted net income elects, then + # the elder, then the first listed. + income = person("adjusted_net_income", period) + other_income = partner(income) + elder = ( + person.get_rank(person.benunit, -person("age", period), condition=spouse) + == 0 + ) + first_on_tie = (income < other_income) | ((income == other_income) & elder) + chooses = (saving_if_allowed > 0) & ( + (saving_if_allowed > other_saving) + | ((saving_if_allowed == other_saving) & first_on_tie) + ) + # The couple's take-up draw is the gaining spouse's, the same draw + # that decided take-up when the transfer was modelled on the + # recipient alone. + partner_would_claim = ( + partner(person("would_claim_marriage_allowance", period)) > 0 + ) + return chooses & partner_would_claim diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py index d54f76158a..ec3ee2b6df 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py @@ -4,8 +4,11 @@ class would_claim_marriage_allowance(Variable): label = "Would claim Marriage Allowance" documentation = ( - "Whether this person would claim Marriage Allowance if eligible. " - "Generated stochastically in the dataset using take-up rates." + "Whether this person would claim Marriage Allowance from their spouse " + "or civil partner's election when it lowers the couple's income tax. " + "Generated stochastically in the dataset using take-up rates. The " + "couple's election follows the value on the spouse who would gain " + "(makes_marriage_allowance_election)." ) entity = Person definition_period = YEAR @@ -19,34 +22,31 @@ class would_claim_marriage_allowance(Variable): class marriage_allowance(Variable): value_type = float entity = Person - label = "Marriage Allowance" + label = "Marriage Allowance received" + documentation = ( + "The transferable amount this person's spouse or civil partner gives " + "up under a Marriage Allowance election. This person's tax falls by " + "the appropriate percentage of it (marriage_allowance_tax_reduction); " + "the allowance itself does not come off their taxable income." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A" + reference = [ + dict( + title="Income Tax Act 2007 s. 55B", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 55C", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ), + ] unit = GBP def formula(person, period, parameters): - marital = person("marital_status", period) - married = marital == marital.possible_values.MARRIED - # The election is between spouses or civil partners (ITA 2007 - # s.55C(1)(a)): the claimant and partner of a married benefit unit, - # never their children. - spouse_or_civil_partner = person("is_claimant_or_partner", period) - eligible = ( - married - & spouse_or_civil_partner - & person("meets_marriage_allowance_income_conditions", period) - ) - transferable_amount = person("partners_unused_personal_allowance", period) - allowances = parameters(period).gov.hmrc.income_tax.allowances - capped_percentage = allowances.marriage_allowance.max - max_amount = allowances.personal_allowance.amount * capped_percentage - amount_if_eligible_pre_rounding = max_(min_(transferable_amount, max_amount), 0) - # Round up. - rounding_increment = allowances.marriage_allowance.rounding_increment - amount_if_eligible = ( - np.ceil(amount_if_eligible_pre_rounding / rounding_increment) - * rounding_increment + # The gaining party is the other spouse or civil partner of the person + # who elects (s. 55C(1)(a)); at most one of the couple elects. + spouse = person("is_marriage_allowance_spouse", period) + given_up = person("makes_marriage_allowance_election", period) * person( + "marriage_allowance_transferable_amount", period ) - # Use pre-generated take-up decision from dataset - would_claim = person("would_claim_marriage_allowance", period) - return eligible * amount_if_eligible * would_claim + return spouse * (person.benunit.sum(spouse * given_up) - given_up) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py new file mode 100644 index 0000000000..1554e2652d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_appropriate_percentage(Variable): + value_type = float + entity = Person + label = "Marriage Allowance appropriate percentage" + documentation = ( + "The rate at which the Marriage Allowance reduces the gaining party's " + "tax: the basic rate, or the Scottish basic rate for a Scottish " + "taxpayer, whatever rate their income is actually taxed at. The Welsh " + "basic rate equals the basic rate in this model." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55B(3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ) + unit = "/1" + + def formula(person, period, parameters): + rates = parameters(period).gov.hmrc.income_tax.rates + # The Scottish basic rate follows the starter rate; before 2018-19 the + # Scottish scale had no starter rate. + scottish = rates.scotland.rates.rates + scottish_basic = scottish[1 if len(scottish) > 3 else 0] + return where( + person("pays_scottish_income_tax", period), + scottish_basic, + rates.uk.rates[0], + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py new file mode 100644 index 0000000000..48fb598dfe --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_relinquished(Variable): + value_type = float + entity = Person + label = "Personal allowance given up under a Marriage Allowance election" + documentation = ( + "The transferable amount by which a Marriage Allowance election cuts " + "the electing person's own personal allowance." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55B(6)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ) + unit = GBP + + def formula(person, period, parameters): + # Only a spouse or civil partner can give allowance up. + elects = person("makes_marriage_allowance_election", period) & person( + "is_marriage_allowance_spouse", period + ) + return elects * person("marriage_allowance_transferable_amount", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py new file mode 100644 index 0000000000..826fcfc7a4 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_tax_reduction(Variable): + value_type = float + entity = Person + label = "Marriage Allowance tax reduction" + documentation = ( + "The gaining party's tax reduction at Step 6 of the income tax " + "calculation: the appropriate percentage of the transferable amount, " + "capped at the tax left to reduce." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55A(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55A", + ), + dict( + title="Income Tax Act 2007 s. 55B(1) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 29(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] + unit = GBP + + def formula(person, period, parameters): + reduction = person("marriage_allowance_appropriate_percentage", period) * ( + person("marriage_allowance", period) + ) + return min_(reduction, person("marriage_allowance_tax_reduction_limit", period)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py new file mode 100644 index 0000000000..82caa9078f --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_tax_reduction_limit(Variable): + value_type = float + entity = Person + label = "Tax available for the Marriage Allowance reduction" + documentation = ( + "Tax at Step 5 of the income tax calculation left after the person's " + "other tax reductions. The Marriage Allowance tax reduction cannot " + "exceed it. Each reduction is limited to the tax left after those " + "before it, so the order changes how the total is split between " + "them but not the total, which is the greatest reduction in " + "liability. Deducting this one last makes it exactly the tax an " + "election saves the gaining spouse. Double taxation relief should " + "come after the Marriage Allowance (s. 27(6)), but other_tax_credits " + "does not separate it out." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 29(2) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + dict( + title="Income Tax Act 2007 s. 27(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/27", + ), + ] + unit = GBP + + def formula(person, period, parameters): + income_tax = parameters(period).gov.hmrc.income_tax + other_reductions = [ + variable + for variable in income_tax.income_tax_subtractions + if variable != "marriage_allowance_tax_reduction" + ] + step_5_tax = person("income_tax_pre_charges", period) + return max_(0, step_5_tax - add(person, period, other_reductions)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py new file mode 100644 index 0000000000..c5ee28febc --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_transferable_amount(Variable): + value_type = float + entity = Person + label = "Marriage Allowance transferable amount" + documentation = ( + "The fixed amount of personal allowance a Marriage Allowance election " + "transfers: 10% of the personal allowance in ITA 2007 s. 35(1), " + "rounded up to a multiple of £10. It does not depend on how much of " + "the transferor's allowance is unused." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55B(4) and (5)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ) + unit = GBP + + def formula(person, period, parameters): + allowances = parameters(period).gov.hmrc.income_tax.allowances + ma = allowances.marriage_allowance + amount = allowances.personal_allowance.amount * ma.max + increment = ma.rounding_increment + share = np.ceil(amount / increment) * increment + # 2015-16 is a fixed amount (s. 55B(4)(a)), whatever the allowance. + return share if ma.fixed_amount == 0 else ma.fixed_amount diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index e91f14cbd3..2063b8b51d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -1,18 +1,87 @@ from policyengine_uk.model_api import * -import numpy as np + + +def liable_only_at_marriage_allowance_rates(person, period, parameters): + """Whether the person pays no income tax above the basic rates. + + ITA 2007 s. 55B(2)(b) and s. 55C(1)(c) allow only the basic rate, the + property, default, savings, Scottish and Welsh basic rates, the Scottish + starter and intermediate rates, the dividend ordinary rate and the nil and + starting rates for savings. So no non-savings income may reach the higher + rate (the Scottish higher rate for a Scottish taxpayer), and no savings the + savings higher rate. Dividends covered by the dividend nil rate still + count: s. 55B(2)(ba) and s. 55C(1)(ca) test the dividend upper rate as if + s. 13A were omitted. + + The bands are the ones the model taxes with: Gift Aid extends the rate + limits by the grossed-up gift (s. 414(2)(b)), as in earned_income_tax, + and does not come off the income. + """ + rates = parameters(period).gov.hmrc.income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) + # Earnings, pensions, property and other non-savings income after + # allowances: the income below savings and dividends (s. 16), from net + # income, not adjusted net income, which deducts the gross gift and the + # pension relief already in allowances (s. 58). + non_savings = person("non_savings_taxable_income", period) + # The Scottish higher rate follows the starter, basic and intermediate + # rates; before 2018-19 the Scottish scale had only the basic rate below it. + scottish = rates.scotland.rates + scottish_higher = scottish.thresholds[3 if len(scottish.rates) > 3 else 1] + non_savings_limit = where( + person("pays_scottish_income_tax", period), + scottish_higher + gross_gift, + rates.uk.thresholds[1] + gross_gift, + ) + savings_above_basic = add( + person, period, ["higher_rate_savings_income", "add_rate_savings_income"] + ) + # Dividends are the top slice, above all non-savings income (property + # included) and savings (s. 16), counted before the dividend allowance; + # the dividend upper rate starts at the basic rate limit (s. 13), extended + # by the gross gift. The savings test above uses the model's own savings + # bands, which Gift Aid extends too. + savings = person("taxable_savings_interest_income", period) + dividends = person("taxable_dividend_income", period) + below_dividends = non_savings + max_( + 0, savings - person("received_allowances_savings_income", period) + ) + dividends_after_allowances = max_( + 0, dividends - person("received_allowances_dividend_income", period) + ) + dividends_above_basic = (dividends_after_allowances > 0) & ( + below_dividends + dividends_after_allowances + > rates.uk.thresholds[1] + gross_gift + ) + return ( + (non_savings <= non_savings_limit) + & (savings_above_basic <= 0) + & ~dividends_above_basic + ) class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" - documentation = "Whether this person (and their partner) meets the conditions for this person to be eligible for the Marriage Allowance, as set out in the Income Tax Act 2007 sections 55B and 55C" + documentation = ( + "Whether this person pays income tax only at the rates that allow a " + "Marriage Allowance election, given their actual allowances. The " + "gaining party must meet this (ITA 2007 s. 55B(2)(b), (ba)); the " + "electing spouse must meet it with their allowance already cut by " + "the transferable amount (s. 55C(1)(c), (ca))." + ) entity = Person definition_period = YEAR value_type = bool - reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" + reference = [ + dict( + title="Income Tax Act 2007 s. 55B(2)(b) and (ba)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 55C(1)(c) and (ca)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ), + ] def formula(person, period, parameters): - band = person("tax_band", period) - eligible_bands = parameters( - period - ).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands - return np.isin(band.decode_to_str(), eligible_bands) + return liable_only_at_marriage_allowance_rates(person, period, parameters) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py deleted file mode 100644 index 9f18828b9f..0000000000 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py +++ /dev/null @@ -1,23 +0,0 @@ -from policyengine_uk.model_api import * -from numpy import ceil - - -class partners_unused_personal_allowance(Variable): - label = "Partner's unused personal allowance" - reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" - documentation = ( - "The personal tax allowance not used by this person's partner, if they exist" - ) - entity = Person - definition_period = YEAR - value_type = float - unit = GBP - - def formula(person, period, parameters): - # Only a spouse or civil partner can transfer allowance (ITA 2007 - # s.55B(5A), s.55C(1)(a)): the other claimant or partner of the - # benefit unit, never a child. People outside the couple get zero, so - # they cannot produce a negative transferable amount. - couple_member = person("is_claimant_or_partner", period) - pa = person("unused_personal_allowance", period) - return person.benunit.sum(couple_member * pa) - couple_member * pa diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py index 8553f40580..8630a6a691 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py @@ -7,10 +7,16 @@ class personal_allowance(Variable): label = "Personal Allowance for the year" unit = GBP definition_period = YEAR - reference = dict( - title="Income Tax Act 2007 s. 35, s. 58", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/35", - ) + reference = [ + dict( + title="Income Tax Act 2007 s. 35, s. 58", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/35", + ), + dict( + title="Income Tax Act 2007 s. 55B(6)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + ] def formula(person, period, parameters): params = parameters(period) @@ -20,4 +26,8 @@ def formula(person, period, parameters): # adjusted_net_income includes the s.58 Gift Aid and pension relief. excess = max_(0, ANI - PA.maximum_ANI) reduction = excess * PA.reduction_rate - return max_(0, np.ceil(personal_allowance - reduction)) + allowance = max_(0, np.ceil(personal_allowance - reduction)) + # A Marriage Allowance election cuts the electing spouse's allowance + # by the transferable amount (s. 55B(6)). + relinquished = person("marriage_allowance_relinquished", period) + return max_(0, allowance - relinquished) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py index a188f51545..2df603ea6a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -115,10 +115,12 @@ class uses_property_allowance(Variable): "two cannot be combined. Full relief applies to receipts within the " "allowance unless the person elects out of it, and partial relief " "only if they elect for it; with finance costs to relieve, each " - "election is made only if it lowers the person's own income tax, " - "including the winter fuel payment charge, with everyone else's " - "route held fixed. Tax before the charge settles that comparison " - "wherever the most the charge could be on either route " + "election is made only if it lowers the income tax of the person's " + "benefit unit, including the winter fuel payment charge, with " + "everyone else's route held fixed. That tax includes the spouse's, " + "which the route can move through the Marriage Allowance election. " + "Tax before the charge settles that comparison " + "wherever the most the charges could be on either route " "(winter_fuel_payment_charge_ceiling) cannot reverse it; the charge " "itself, which needs everyone's benefits on each route, is worked " "out only where it can. Where an input or a structural reform " @@ -208,6 +210,10 @@ def formula(person, period, parameters): changed = np.zeros(person.count, dtype=bool) done = deciders == 0 bounded = ceiling_bounds_the_charge(simulation, period) + # Each person's choice weighs their benefit unit's tax: a route can + # change whether a spouse elects for the Marriage Allowance, which + # moves the other spouse's tax (#1947). + benunit_total = person.benunit.sum while not done.all(): deciding = choosing & ~done & (order == rank) using = route | deciding @@ -223,14 +229,19 @@ def formula(person, period, parameters): not_using, BEFORE_CHARGE, ) - # On each route the charge is between nothing and its - # ceiling, so the allowance saves at least the saving before - # the charge less the ceiling using it, and at most that - # saving plus the ceiling not using it. Where those bounds - # share a sign, beyond a penny and float32 rounding, the - # choice is the one tax including the charge gives. - saving = tax_not_using - tax_using - rounding = 0.01 + 1e-6 * (abs(tax_using) + abs(tax_not_using)) + # On each route each member's charge is between nothing and + # their ceiling, so the allowance saves the benefit unit at + # least the saving before the charge less the ceilings using + # it, and at most that saving plus the ceilings not using it. + # Where those bounds share a sign, beyond a penny and float32 + # rounding, the choice is the one tax including the charge + # gives. + most_charge_using = benunit_total(most_charge_using) + most_charge_not_using = benunit_total(most_charge_not_using) + saving = benunit_total(tax_not_using) - benunit_total(tax_using) + rounding = 0.01 + 1e-6 * benunit_total( + abs(tax_using) + abs(tax_not_using) + ) unsettled = ( deciding & ((most_charge_using > 0) | (most_charge_not_using > 0)) @@ -250,8 +261,9 @@ def formula(person, period, parameters): else: # Every household's next decision turns on the charge: # compare them all on income tax including it, worked out on - # each route where some ceiling is above nothing (where it is - # nothing, so is the charge, and tax before it is the tax). + # each route where some benefit unit's ceilings are above + # nothing (where they are nothing, so are its members' + # charges, and tax before the charge is the tax). if (unsettled & (most_charge_using > 0)).any(): (tax_using,) = with_route( simulation, @@ -269,7 +281,9 @@ def formula(person, period, parameters): ["income_tax"], ) # Tax is computed to the penny; smaller differences are rounding. - saving = np.round(tax_not_using - tax_using, 2) + saving = np.round( + benunit_total(tax_not_using) - benunit_total(tax_using), 2 + ) prefers_allowance = where(full_relief, saving >= 0, saving > 0) decided = where(deciding, prefers_allowance, route) changed = changed | person.household.any(decided != route) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py index 8e1d2253a4..c95668545c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py @@ -12,10 +12,10 @@ class non_savings_taxable_income(Variable): "earned_taxable_income leaves property income out because " "property_income_tax taxes it separately, but property income still " "fills the bands before savings and dividends do (from 2027-28, " - "s. 16A places it immediately below savings income). With no Marriage " - "Allowance it equals non_savings_income_after_allowances; " - "earned_taxable_income also subtracts Marriage Allowance, which the " - "law gives as a tax reduction (#1947)." + "s. 16A places it immediately below savings income). It equals " + "non_savings_income_after_allowances. The Marriage Allowance does not " + "come off it: the law gives it as a tax reduction " + "(marriage_allowance_tax_reduction)." ) definition_period = YEAR reference = [ diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py index b23956b772..7c0f2113fa 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py @@ -1,5 +1,7 @@ from policyengine_uk.model_api import * +DEDUCTED_AFTER = ["property_finance_cost_relief", "marriage_allowance_tax_reduction"] + class property_finance_cost_relief(Variable): value_type = float @@ -42,12 +44,14 @@ def formula(person, period, parameters): ) # The order of Step 6 reductions does not change their total, so the # person's other reductions come first; this one is limited to the - # Step 5 tax they leave. Step 7 charges (ITA 2007 s. 30), such as the - # High Income Child Benefit Charge, are not reduced. + # Step 5 tax they leave. The Marriage Allowance reduction comes after + # this one, so it is exactly the tax an election saves the gaining + # spouse. Step 7 charges (ITA 2007 s. 30), such as the High Income + # Child Benefit Charge, are not reduced. other_reductions = [ variable for variable in income_tax.income_tax_subtractions - if variable != "property_finance_cost_relief" + if variable not in DEDUCTED_AFTER ] tax_left = max_( 0,