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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2863.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 3 additions & 5 deletions docs/book/validation/student-loan-repayments.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"- **Plan 1**: loans for courses begun on or before 31 August 2012 under England's and Wales's student support regulations ([SI 2009/470 reg. 3A](https://www.legislation.gov.uk/uksi/2009/470/regulation/3A)), and every Student Finance Northern Ireland loan ([GOV.UK](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on)). 9% of income above £24,990.\n",
"- **Plan 2**: Student Finance England loans for courses begun from 1 September 2012 to 31 July 2023, and Student Finance Wales loans for courses begun on or after 1 September 2012 ([GOV.UK](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on)). 9% of income above £27,295.\n",
"- **Plan 4**: loans under Scottish student loan regulations from 1998 onwards whose repayments the Scottish Ministers have HMRC collect ([SI 2009/470 reg. 3D](https://www.legislation.gov.uk/uksi/2009/470/regulation/3D)). GOV.UK puts every Student Awards Agency Scotland loan on Plan 4. 9% of income above £31,395.\n",
"- **Plan 5**: Student Finance England loans for courses begun on or after 1 August 2023 ([GOV.UK](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on)). Its threshold is £25,000, but SI 2009/470 reg. 15(2BA), which [SI 2022/1335 reg. 15(9)](https://www.legislation.gov.uk/uksi/2022/1335/regulation/15/made) inserted, requires no repayment of a Plan 5 loan on or before 5 April 2026, so Plan 5 borrowers owe nothing in 2024-25 or 2025-26. The model doesn't yet apply reg. 15(2BA): it charges Plan 5 borrowers 9% of income above £25,000 in those years too.\n",
"- **Plan 5**: Student Finance England loans for courses begun on or after 1 August 2023 ([GOV.UK](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on)). Its threshold is £25,000, but SI 2009/470 reg. 15(2BA), which [SI 2022/1335 reg. 15(9)](https://www.legislation.gov.uk/uksi/2022/1335/regulation/15/made) inserted, requires no repayment of a Plan 5 loan on or before 5 April 2026, so Plan 5 borrowers owe nothing in 2024-25 or 2025-26. From 2026-27 they repay 9% of income above that threshold.\n",
"- **Postgraduate**: 6% of income above £21,000.\n",
"\n",
"The FRS captures reported student loan repayments, while PolicyEngine calculates repayments based on income and loan plan type."
Expand Down Expand Up @@ -146,17 +146,15 @@
"source": [
"## What can make the two measures differ\n",
"\n",
"The code compares two variables. `student_loan_repayments` holds the repayments a dataset reports; where a dataset supplies none, its formula returns `student_loan_repayment`, and the two match exactly. `student_loan_repayment` applies the plan's rate to income above the plan's threshold, and caps the result at `student_loan_balance` where the balance is known. They can differ for these reasons:\n",
"The code compares two variables. `student_loan_repayments` holds the repayments a dataset reports; where a dataset supplies none, its formula returns `student_loan_repayment`, and the two match exactly. `student_loan_repayment` applies the plan's rate to income above the plan's threshold, charges a Plan 5 borrower nothing before 2026-27, and caps the result at `student_loan_balance` where the balance is known. They can differ for these reasons:\n",
"\n",
"1. **Annual income**: the model applies the threshold to a year's income (`student_loan_income`, net income less benefits in kind), not to each pay period, so it doesn't reflect how income varies within the year.\n",
"\n",
"2. **Voluntary repayments**: the formula holds no voluntary repayments, so it leaves out any that a reported figure includes.\n",
"\n",
"3. **One plan per person**: `student_loan_plan` gives each person one plan, so the model can't charge someone who holds loans on two plans for both.\n",
"\n",
"4. **Missing plans**: a person with no plan (`NONE`) gets no modelled repayment, whatever they report. [#1764](https://github.com/PolicyEngine/policyengine-uk/issues/1764) reports a run on `enhanced_frs_2023_24.h5` with policyengine-uk 2.88.43 for 2026 in which 5,570 people had a reported repayment and none had a modelled one, because no person had a plan.\n",
"\n",
"5. **Plan 5 before April 2026**: the model charges Plan 5 borrowers repayments that the law doesn't require until 6 April 2026 (see the plan list above)."
"4. **Missing plans**: a person with no plan (`NONE`) gets no modelled repayment, whatever they report. [#1764](https://github.com/PolicyEngine/policyengine-uk/issues/1764) reports a run on `enhanced_frs_2023_24.h5` with policyengine-uk 2.88.43 for 2026 in which 5,570 people had a reported repayment and none had a modelled one, because no person had a plan."
]
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
171 changes: 171 additions & 0 deletions policyengine_uk/tests/test_plan_5_repayment_start_properties.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
Loading