From 4c9c8b177532798c93ebe523764cca94560f1ef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:14:22 +0000 Subject: [PATCH] Charge no Plan 5 student loan repayment before 6 April 2026 Add gov.hmrc.student_loans.repayments_due.plan_5, false until 6 April 2026 (SI 2009/470 reg. 15(2BA) and (2BB)), and have student_loan_repayment charge Plan 5 borrowers nothing while it is false. Fixes #2863 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6FfQ4R4oAVPwJQBrx4 --- changelog.d/2863.fixed.md | 1 + .../validation/student-loan-repayments.ipynb | 2 +- .../student_loans/repayments_due/plan_5.yaml | 18 ++ .../student_loans/plan_5_repayment_start.yaml | 70 +++++++ .../test_plan_5_repayment_start_properties.py | 171 ++++++++++++++++++ .../student_loans/student_loan_repayment.py | 8 +- 6 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 changelog.d/2863.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml create mode 100644 policyengine_uk/tests/test_plan_5_repayment_start_properties.py diff --git a/changelog.d/2863.fixed.md b/changelog.d/2863.fixed.md new file mode 100644 index 0000000000..f15cc8fa6f --- /dev/null +++ b/changelog.d/2863.fixed.md @@ -0,0 +1 @@ +The student loan repayment formula now charges Plan 5 borrowers nothing before 6 April 2026, as SI 2009/470 reg. 15(2BA) requires. diff --git a/docs/book/validation/student-loan-repayments.ipynb b/docs/book/validation/student-loan-repayments.ipynb index d6b90f76e6..b49bdf2cf2 100644 --- a/docs/book/validation/student-loan-repayments.ipynb +++ b/docs/book/validation/student-loan-repayments.ipynb @@ -20,7 +20,7 @@ "- **Plan 1** (pre-2012 England/Wales, Scotland, NI): 9% of income above £24,990 (2024-25)\n", "- **Plan 2** (post-2012 England/Wales): 9% of income above £27,295 (2024-25)\n", "- **Plan 4** (Scotland post-2017): 9% of income above £27,660 (2024-25)\n", - "- **Plan 5** (England post-2023): 9% of income above £25,000 (2024-25)\n", + "- **Plan 5** (England post-2023): nothing before 6 April 2026 (SI 2009/470 reg. 15(2BA)), then 9% of income above £25,000 from 2026-27\n", "- **Postgraduate**: 6% of income above £21,000 (2024-25)\n", "\n", "The FRS captures reported student loan repayments, while PolicyEngine calculates repayments based on income and loan plan type." diff --git a/policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml b/policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml new file mode 100644 index 0000000000..59c96cf873 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml @@ -0,0 +1,18 @@ +description: Plan 5 student loan borrowers owe repayments if this is true. A borrower need not repay any part of a Plan 5 loan on or before 5 April 2026, so from 6 April 2026 the student loan repayment formula charges Plan 5 borrowers. +values: + 2015-04-06: false + 2026-04-06: + value: true + metadata: + reference: + - title: SI 2009/470 reg. 15(2BB) (omits paragraph (2BA) on 6 April 2026) + href: https://www.legislation.gov.uk/uksi/2009/470/regulation/15 +metadata: + unit: bool + period: year + label: Plan 5 student loan repayments due + reference: + - title: SI 2009/470 reg. 15(2BA) and (2BB), inserted by SI 2022/1335 reg. 15(9) + href: https://www.legislation.gov.uk/uksi/2009/470/regulation/15 + - title: SI 2022/1335 reg. 15(9) + href: https://www.legislation.gov.uk/uksi/2022/1335/regulation/15/made diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml new file mode 100644 index 0000000000..b5864a09ac --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml @@ -0,0 +1,70 @@ +# SI 2009/470 reg. 15(2BA), inserted by SI 2022/1335 reg. 15(9): "a borrower +# is not required to repay any part of a plan 5 loan on or before 5th April +# 2026". Reg. 15(2BB) omits (2BA) on 6 April 2026. +# +# The model reads year Y's parameters at 30 April of Y, so 2025 reads the law +# for 2025-26 (no Plan 5 repayment due) and 2026 reads 2026-27 (due). +# Repayment income here equals employment income: there is no other income, +# deduction or benefit in kind. + +- name: A Plan 5 borrower on £40,000 owes nothing in 2024-25 + period: 2024 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: PLAN_5 + output: + # Reg. 15(2BA) applies, so nil. + student_loan_repayment: 0 + +- name: A Plan 5 borrower on £40,000 owes nothing in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: PLAN_5 + output: + # Reg. 15(2BA) applies, so nil. + student_loan_repayment: 0 + +- name: A Plan 5 borrower on £40,000 repays 9% above £25,000 in 2026-27 + period: 2026 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: PLAN_5 + output: + # 0.09 x (40,000 - 25,000) = 0.09 x 15,000 = 1,350 + student_loan_repayment: 1_350 + +- name: A Plan 5 borrower on £200,000 owes nothing in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 200_000 + student_loan_plan: PLAN_5 + output: + # Reg. 15(2BA) applies however far income exceeds the £25,000 threshold. + student_loan_repayment: 0 + +- name: A Plan 2 borrower on £60,000 repays 9% above £28,470 in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 60_000 + student_loan_plan: PLAN_2 + output: + # Plan 2 threshold from 6 April 2025: £28,470. + # 0.09 x (60,000 - 28,470) = 0.09 x 31,530 = 2,837.70 + student_loan_repayment: 2_837.70 + +- name: A postgraduate borrower on £40,000 repays 6% above £21,000 in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: POSTGRADUATE + output: + # Postgraduate threshold £21,000, rate 6%. + # 0.06 x (40,000 - 21,000) = 0.06 x 19,000 = 1,140 + student_loan_repayment: 1_140 diff --git a/policyengine_uk/tests/test_plan_5_repayment_start_properties.py b/policyengine_uk/tests/test_plan_5_repayment_start_properties.py new file mode 100644 index 0000000000..63d803320d --- /dev/null +++ b/policyengine_uk/tests/test_plan_5_repayment_start_properties.py @@ -0,0 +1,171 @@ +"""Plan 5 student loan repayments start on 6 April 2026. + +SI 2009/470 reg. 15(2BA), inserted by SI 2022/1335 reg. 15(9): "Despite +paragraphs (2), (2A) and (2B), a borrower is not required to repay any part of +a plan 5 loan on or before 5th April 2026." Reg. 15(2BB) omits (2BA) on 6 April +2026. The model reads year Y's parameters at 30 April of Y, so the years 2015 +to 2025 carry no Plan 5 liability and 2026 onwards do. + +Each property draws borrowers on every plan, batched into one Simulation per +example, and compares the model with ``reference_repayment``: the formula +without a Plan 5 start date, which charges the plan's rate on repayment income +above the plan's threshold, capped by a positive balance. + +Invariants: + +1. P1: a Plan 5 borrower repays nothing in each year from 2015 to 2025, for + any income and any balance. +2. P2: from 2026, every borrower's repayment equals the reference. +3. P3: a borrower on any plan other than Plan 5 repays the reference amount in + every year from 2015. +4. P4: a repayment never falls below nil and never exceeds a positive balance. +""" + +from unittest.mock import patch + +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.tax_benefit_system import system + +_PROPERTY_SYSTEM = system.clone() +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +PLANS = ("NONE", "PLAN_1", "PLAN_2", "PLAN_4", "PLAN_5", "POSTGRADUATE") +THRESHOLD_KEYS = { + "PLAN_1": "plan_1", + "PLAN_2": "plan_2", + "PLAN_4": "plan_4", + "PLAN_5": "plan_5", + "POSTGRADUATE": "postgraduate", +} +FIRST_YEAR = 2015 +PLAN_5_FIRST_YEAR = 2026 +LAST_YEAR = 2035 + +borrowers = st.lists( + st.tuples( + st.sampled_from(PLANS), + st.integers(0, 300_000), + # Zero means no balance is known, so no cap applies. + st.one_of(st.just(0), st.integers(1, 100_000)), + ), + min_size=1, + max_size=8, +) +plan_5_borrowers = st.lists( + st.tuples( + st.just("PLAN_5"), + st.integers(0, 1_000_000), + st.one_of(st.just(0), st.integers(1, 200_000)), + ), + min_size=1, + max_size=8, +) + + +def simulate(people, year): + """One Simulation with each (plan, employment income, balance) its own + household.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (plan, income, balance) in enumerate(people): + name = f"p{i}" + situation["people"][name] = { + "age": {year: 30}, + "employment_income": {year: income}, + "student_loan_plan": {year: plan}, + "student_loan_balance": {year: balance}, + } + situation["benunits"][f"b{i}"] = {"members": [name]} + situation["households"][f"h{i}"] = {"members": [name]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + return Simulation(situation=situation) + + +def reference_repayment(sim, people, year): + """Repayments from the plan's rate and threshold alone, with no Plan 5 + start date.""" + p = sim.tax_benefit_system.parameters(str(year)).gov.hmrc.student_loans + net_income = sim.calculate("net_income", year) + benefits = np.minimum( + sim.calculate("taxable_employment_income", year), + sim.calculate("benefits_in_kind", year), + ) + income = np.maximum(net_income - benefits, 0) + out = [] + for (plan, _, balance), person_income in zip(people, income): + if plan == "NONE": + out.append(0.0) + continue + threshold = p.thresholds[THRESHOLD_KEYS[plan]] + rate = ( + p.postgraduate_repayment_rate + if plan == "POSTGRADUATE" + else p.repayment_rate + ) + repayment = rate * max(0.0, person_income - threshold) + out.append(min(repayment, balance) if balance > 0 else repayment) + return np.array(out) + + +@pytest.fixture(scope="module", autouse=True) +def _release_simulation(): + yield + _PROPERTY_SYSTEM.simulation = None + + +@PROPERTY_SETTINGS +@given( + people=plan_5_borrowers, + year=st.integers(FIRST_YEAR, PLAN_5_FIRST_YEAR - 1), +) +def test_p1_plan_5_borrowers_repay_nothing_before_2026_27(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + assert np.all(repayment == 0) + + +@PROPERTY_SETTINGS +@given(people=borrowers, year=st.integers(PLAN_5_FIRST_YEAR, LAST_YEAR)) +def test_p2_every_plan_follows_threshold_and_rate_from_2026_27(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + np.testing.assert_allclose( + repayment, reference_repayment(sim, people, year), rtol=1e-6, atol=0.01 + ) + + +@PROPERTY_SETTINGS +@given( + people=borrowers.map( + lambda people: [person for person in people if person[0] != "PLAN_5"] + ).filter(bool), + year=st.integers(FIRST_YEAR, LAST_YEAR), +) +def test_p3_other_plans_unchanged_in_every_year(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + np.testing.assert_allclose( + repayment, reference_repayment(sim, people, year), rtol=1e-6, atol=0.01 + ) + + +@PROPERTY_SETTINGS +@given(people=borrowers, year=st.integers(FIRST_YEAR, LAST_YEAR)) +def test_p4_repayment_is_bounded_by_nil_and_balance(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + balance = np.array([person[2] for person in people], dtype=float) + assert np.all(repayment >= 0) + capped = balance > 0 + assert np.all(repayment[capped] <= balance[capped] + 1e-6) diff --git a/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py b/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py index cd24f7df64..6be67396a3 100644 --- a/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py +++ b/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py @@ -13,7 +13,9 @@ class student_loan_repayment(Variable): "Annual student loan repayment calculated from income and plan type. " "Repayments are 9% of income above threshold for Plans 1/2/4/5, " "and 6% for Postgraduate loans, capped by the outstanding balance " - "when that balance is available." + "when that balance is available. Plan 5 borrowers repay nothing " + "before 6 April 2026 (SI 2009/470 reg. 15(2BA) and (2BB)), so the " + "formula charges them nil for 2025-26 and earlier years." ) definition_period = YEAR unit = GBP @@ -46,6 +48,10 @@ def formula(person, period, parameters): rate = person("student_loan_repayment_rate", period) uncapped_repayment = rate * max_(0, income - threshold) + # SI 2009/470 reg. 15(2BA) and (2BB): no Plan 5 repayment is due on or + # before 5 April 2026. + repayment_due = (plan != StudentLoanPlan.PLAN_5) | p.repayments_due.plan_5 + uncapped_repayment = where(repayment_due, uncapped_repayment, 0) return where(balance > 0, min_(uncapped_repayment, balance), uncapped_repayment)