diff --git a/changelog.d/fix-new-state-pension-reported.fixed.md b/changelog.d/fix-new-state-pension-reported.fixed.md new file mode 100644 index 000000000..0c32f3376 --- /dev/null +++ b/changelog.d/fix-new-state-pension-reported.fixed.md @@ -0,0 +1 @@ +Replace `new_state_pension`'s flat-max payout with a `min(reported, max) / max * period_max` formula mirroring `basic_state_pension`, and extend `additional_state_pension` to NEW-type retirees so any pre-2016 SERPS/S2P Protected Payment flows through as an add-on instead of being silently dropped. Partial-NI-record retirees now receive their actual pro-rated rate rather than the full flat max. Closes part of the ~£12 bn residual state-pension gap vs the OBR target tracked in #1632. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/new_state_pension.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/new_state_pension.yaml new file mode 100644 index 000000000..b5a4287ac --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/new_state_pension.yaml @@ -0,0 +1,86 @@ +- name: New State Pension uses reported amount, not flat max + # Under 35 NI years — partial rate should be preserved, not paid at max. + period: 2021 + input: + people: + person1: + state_pension_reported: 7000 # Below max (9339.20 = 179.60 * 52) + state_pension_type: NEW + age: 67 + output: + new_state_pension: 7000 + +- name: New State Pension equals max for full-rate retirees + period: 2021 + input: + people: + person1: + state_pension_reported: 9339.20 # Exactly the 2021 max + state_pension_type: NEW + age: 67 + output: + new_state_pension: 9339.20 + +- name: New State Pension caps at max; Protected Payment flows to additional_state_pension + # Pre-2016 SERPS/S2P accrual — reported exceeds flat max. The flat-rate + # component stays capped at the max, and the excess is treated as a + # Protected Payment top-up tracked via additional_state_pension. + period: 2021 + input: + people: + person1: + state_pension_reported: 12000 # Above max — Protected Payment + state_pension_type: NEW + age: 67 + output: + new_state_pension: 9339.20 + additional_state_pension: 2660.80 # 12000 − 9339.20 + +- name: Additional State Pension captures SERPS/S2P for BASIC retirees + period: 2021 + input: + people: + person1: + state_pension_reported: 9000 # Above basic max (7155.20) + state_pension_type: BASIC + age: 70 + output: + additional_state_pension: 1844.80 # 9000 − 7155.20 + +- name: NEW-type retiree with zero reported pension gets zero NSP + # Deferral or FRS non-report. Matches reality — a deferred NSP claimant + # genuinely receives nothing until they start drawing. This differs from + # the old formula, which paid the flat max regardless of reported. + period: 2021 + input: + people: + person1: + state_pension_reported: 0 + state_pension_type: NEW + age: 67 + output: + new_state_pension: 0 + additional_state_pension: 0 + +- name: Basic type returns zero for new_state_pension + period: 2021 + input: + people: + person1: + state_pension_reported: 5000 + state_pension_type: BASIC + age: 70 + output: + new_state_pension: 0 + +- name: New State Pension is zero when max is zero + period: 2021 + input: + gov.dwp.state_pension.new_state_pension.amount: 0 + people: + person1: + state_pension_reported: 9000 + state_pension_type: NEW + age: 67 + output: + new_state_pension: 0 diff --git a/policyengine_uk/variables/gov/dwp/additional_state_pension.py b/policyengine_uk/variables/gov/dwp/additional_state_pension.py index fcf87f001..2e77b4f77 100644 --- a/policyengine_uk/variables/gov/dwp/additional_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/additional_state_pension.py @@ -10,25 +10,45 @@ class additional_state_pension(Variable): def formula(person, period, parameters): simulation = person.simulation - if simulation.dataset is None: - return 0 - try: - data_year = min(simulation.dataset.years) - except: + has_dataset = simulation.dataset is not None + if has_dataset: + try: + data_year = min(simulation.dataset.years) + except: + data_year = period.start.year + else: data_year = period.start.year reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR - type = person("state_pension_type", data_year) - sp_amount = parameters.gov.dwp.state_pension.basic_state_pension.amount - max_sp_data_year = sp_amount(data_year) - max_sp_period = sp_amount(period) + pension_type = person("state_pension_type", data_year) + types = pension_type.possible_values + + bsp_amount = parameters.gov.dwp.state_pension.basic_state_pension.amount + nsp_amount = parameters.gov.dwp.state_pension.new_state_pension.amount + + # Each pension type has its own flat-rate ceiling; anything above + # the ceiling is an add-on: + # BASIC → SERPS / S2P (pre-2016 earnings-related top-up) + # NEW → Protected Payment (pre-2016 accrual exceeding the + # new flat rate, folded into NSP under current law) + max_for_type_data = select( + [pension_type == types.BASIC, pension_type == types.NEW], + [bsp_amount(data_year), nsp_amount(data_year)], + default=0, + ) + max_for_type_period = select( + [pension_type == types.BASIC, pension_type == types.NEW], + [bsp_amount(period), nsp_amount(period)], + default=0, + ) + amount_in_data_year = where( - type == type.possible_values.BASIC, - max_(reported - max_sp_data_year, 0), + pension_type != types.NONE, + max_(reported - max_for_type_data, 0), 0, ) uprating = where( - max_sp_data_year > 0, - max_sp_period / max_sp_data_year, + max_for_type_data > 0, + max_for_type_period / max_for_type_data, 1, ) return amount_in_data_year * uprating * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/new_state_pension.py b/policyengine_uk/variables/gov/dwp/new_state_pension.py index 6cfc9be7d..bd99d7065 100644 --- a/policyengine_uk/variables/gov/dwp/new_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/new_state_pension.py @@ -10,10 +10,33 @@ class new_state_pension(Variable): def formula(person, period, parameters): simulation = person.simulation - if simulation.dataset is None: - return 0 + has_dataset = simulation.dataset is not None - type = person("state_pension_type", period) - eligible = type == type.possible_values.NEW - p = parameters(period).gov.dwp.state_pension.new_state_pension - return eligible * p.amount * WEEKS_IN_YEAR + if has_dataset: + try: + data_year = min(simulation.dataset.years) + except: + data_year = period.start.year + else: + data_year = period.start.year + + 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 + p = parameters.gov.dwp.state_pension.new_state_pension + max_new_data_year = p.amount(data_year) + max_new_period = p.amount(period) + + # Pro-rate by reported amount, capped at the flat max so a partial + # NI record gets the appropriate partial rate. Any reported amount + # above the flat max is Protected Payment (pre-2016 SERPS/S2P) and + # flows through ``additional_state_pension`` — mirrors the + # BASIC / ASP split so ``new_state_pension`` is always the headline + # flat-rate component. + share = where( + max_new_data_year > 0, + min_(reported_weekly, max_new_data_year) / max_new_data_year, + 0, + ) + return eligible * share * max_new_period * WEEKS_IN_YEAR diff --git a/uv.lock b/uv.lock index 5e4c8dc15..3591c22cb 100644 --- a/uv.lock +++ b/uv.lock @@ -1584,7 +1584,7 @@ wheels = [ [[package]] name = "policyengine-uk" -version = "2.88.3" +version = "2.88.5" source = { editable = "." } dependencies = [ { name = "microdf-python", marker = "python_full_version >= '3.11'" },