education.student-loan-repayment
A graduate's student or postgraduate loan repayment for a tax year, as Self Assessment works it out.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 21 tests, run in TypeScript, Python and Rust.
Not professional advice. This capability calculates payroll figures from published rules. It is a software component for developers, not tax advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a payroll specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
The student loan or postgraduate loan repayment a graduate owes for a whole tax year, the way HMRC works it out from a Self Assessment return: 9% (plans 1, 2, 4 and 5) or 6% (postgraduate loan) of income over the plan's annual threshold, less what employers already deducted through PAYE.
## Built on payroll.student-loan
For example
studentLoanRepayment(£42,000.00, £0.00, plan-1, 2026-07-01, £0.00)→ tax year 2026/27, threshold £26,900.00, rate 9%, counted income £42,000.00, repayment £1,359.00, deducted through paye £0.00, balance £1,359.00 GOV.UK's example: plan 1 on £42,000 in 2026/27 repays £1,359studentLoanRepayment(£42,000.00, £0.00, plan-1, 2026-07-01, £1,200.00)→ tax year 2026/27, threshold £26,900.00, rate 9%, counted income £42,000.00, repayment £1,359.00, deducted through paye £1,200.00, balance £159.00 the same, with £1,200 already deducted through PAYE: £159 left to paystudentLoanRepayment(£30,000.00, £0.00, postgraduate, 2026-07-01, £0.00)→ tax year 2026/27, threshold £21,000.00, rate 6%, counted income £30,000.00, repayment £540.00, deducted through paye £0.00, balance £540.00 postgraduate loan: 6% over £21,000
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function studentLoanRepayment(earnedIncome: Money, unearnedIncome: Money, plan: StudentLoanPlan, taxYearDate: string, deductedThroughPaye: Money): StudentLoanRepayment
| earnedIncome | Money | the year's earned income: pay and self-employed profits, in GBP |
| unearnedIncome | Money | the year's unearned income: savings interest, dividends, rental profit |
| plan | StudentLoanPlan | the one plan or loan to work out; a postgraduate loan alongside another plan needs two calls |
| taxYearDate | date | any date in the tax year: 2026-07-01 means 2026/27 |
| deductedThroughPaye | Money | what employers already deducted for this plan in the year |
| returns | StudentLoanRepayment | the year's repayment and what is still to pay |
The type it declares, generated into your project
/** One plan's repayment for one tax year. */
export interface StudentLoanRepayment {
/** HMRC's label, 2026/27 */
readonly taxYear: string;
/** the plan's annual repayment threshold */
readonly threshold: Money;
/** 900 (9%) for plans 1, 2, 4 and 5; 600 (6%) for the postgraduate loan */
readonly basisPoints: number;
/** earned income, plus unearned income when that is over £2,000 */
readonly countedIncome: Money;
/** the rate on countedIncome over the threshold, whole pounds rounded down */
readonly repayment: Money;
readonly deductedThroughPaye: Money;
/** repayment less PAYE deductions; negative when too much was deducted */
readonly balance: Money;
}
Your code names it in one line, in the file that uses it
import { studentLoanRepayment } from "#fune/education.student-loan-repayment@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { ukTaxYear } from "./dates_uk_tax_year.ts"; ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
import { STUDENT_LOAN_THRESHOLDS, STUDENT_LOAN_THRESHOLDS_HISTORY, STUDENT_LOAN_THRESHOLDS_HORIZON } from "./payroll_student_loan_data.ts"; ← payroll.student-loan’s rule data (^1.0.0) · built alongside by fune
import { type StudentLoanPlan } from "./payroll_student_loan_types.ts";
import { UNEARNED_INCOME_LIMITS, UNEARNED_INCOME_LIMITS_HISTORY, UNEARNED_INCOME_LIMITS_HORIZON } from "./education_student_loan_repayment_data.ts"; ← this capability’s own data, compiled from data/unearned-income-limits.json into the same file by fune build
import { type StudentLoanRepayment } from "./education_student_loan_repayment_types.ts";
const PLANS = ["plan-1", "plan-2", "plan-4", "plan-5", "postgraduate"];
function gbp(value: Money, name: string): number {
if (value.currency !== "GBP") {
throw new RangeError(`${name} must be in GBP, received ${value.currency}`);
}
if (!Number.isInteger(value.minor) || value.minor < 0) {
throw new RangeError(`${name} must not be negative, received ${value.minor}`);
}
return value.minor;
}
/**
* One plan's repayment for a tax year, from the whole year's income.
*
* The threshold is payroll.student-loan's row in force on 6 April of the tax
* year; the repayment drops its pence, as HMRC does.
*/
export function studentLoanRepayment(
earnedIncome: Money,
unearnedIncome: Money,
plan: StudentLoanPlan,
taxYearDate: string,
deductedThroughPaye: Money,
): StudentLoanRepayment {
const earned = gbp(earnedIncome, "earnedIncome");
const unearned = gbp(unearnedIncome, "unearnedIncome");
const deducted = gbp(deductedThroughPaye, "deductedThroughPaye");
if (!PLANS.includes(plan)) {
throw new RangeError(`unknown student loan plan "${plan}"`);
}
const year = ukTaxYear(taxYearDate);
const start = year.start;
const rule = STUDENT_LOAN_THRESHOLDS.find((r) => r.plan === plan && start >= r.validFrom && (r.validTo === null || start <= r.validTo));
if (rule === undefined) {
if (STUDENT_LOAN_THRESHOLDS_HISTORY !== "full" && STUDENT_LOAN_THRESHOLDS_HORIZON !== null && start < STUDENT_LOAN_THRESHOLDS_HORIZON) {
throw new RangeError(
`no student loan threshold for ${plan} in tax year ${year.label}: this build was installed with history=${STUDENT_LOAN_THRESHOLDS_HISTORY}, so it only carries rules from ${STUDENT_LOAN_THRESHOLDS_HORIZON}. Reinstall with history=full for earlier tax years.`,
);
}
throw new RangeError(`no student loan threshold for ${plan} in tax year ${year.label}`);
}
const limit = UNEARNED_INCOME_LIMITS.find((r) => start >= r.validFrom && (r.validTo === null || start <= r.validTo));
if (limit === undefined) {
if (UNEARNED_INCOME_LIMITS_HISTORY !== "full" && UNEARNED_INCOME_LIMITS_HORIZON !== null && start < UNEARNED_INCOME_LIMITS_HORIZON) {
throw new RangeError(
`no unearned income limit for tax year ${year.label}: this build was installed with history=${UNEARNED_INCOME_LIMITS_HISTORY}, so it only carries rules from ${UNEARNED_INCOME_LIMITS_HORIZON}. Reinstall with history=full for earlier tax years.`,
);
}
throw new RangeError(`no unearned income limit for tax year ${year.label}`);
}
const counted = earned + (unearned > limit.limit ? unearned : 0);
const excess = counted - rule.annualThreshold;
// Pence x basis points stays well inside 2^53 for any real income.
const pounds = excess <= 0 ? 0 : Math.floor((excess * rule.basisPoints) / (10000 * 100));
const repayment = pounds * 100;
return {
taxYear: year.label,
threshold: money(rule.annualThreshold, "GBP"),
basisPoints: rule.basisPoints,
countedIncome: money(counted, "GBP"),
repayment: money(repayment, "GBP"),
deductedThroughPaye: money(deducted, "GBP"),
balance: money(repayment - deducted, "GBP"),
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add education.student-loan-repayment
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./education.student-loan-repayment-1.0.1-typescript.fune, or fetch it from a terminal with fune pull education.student-loan-repayment@1.0.1:typescript.
The whole function, every language, is one file too: education.student-loan-repayment-1.0.1.fune, 33,412 bytes, sha256 e750beb4fbaf6fccfbe382ece125611243d251c393f896d892cd373c7f3dbaf7. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before education.student-loan-repayment
after — your function gets the result and the arguments, and returns the final result.
// fune: after education.student-loan-repayment
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace dates.uk-tax-year in education.student-loan-repayment
// fune: replace money.amount in education.student-loan-repayment
// fune: replace payroll.student-loan in education.student-loan-repayment
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show education.student-loan-repayment --steps.
// fune: step education.student-loan-repayment after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| GOV.UK's example: plan 1 on £42,000 in 2026/27 repays £1,359 | £42,000.00, £0.00, plan-1, 2026-07-01, £0.00 | → | tax year 2026/27, threshold £26,900.00, rate 9%, counted income £42,000.00, repayment £1,359.00, deducted through paye £0.00, balance £1,359.00 |
| the same, with £1,200 already deducted through PAYE: £159 left to pay | £42,000.00, £0.00, plan-1, 2026-07-01, £1,200.00 | → | tax year 2026/27, threshold £26,900.00, rate 9%, counted income £42,000.00, repayment £1,359.00, deducted through paye £1,200.00, balance £159.00 |
| postgraduate loan: 6% over £21,000 | £30,000.00, £0.00, postgraduate, 2026-07-01, £0.00 | → | tax year 2026/27, threshold £21,000.00, rate 6%, counted income £30,000.00, repayment £540.00, deducted through paye £0.00, balance £540.00 |
| pence are dropped: plan 1 2025/26 on £30,000 is £354.15, so £354 | £30,000.00, £0.00, plan-1, 2025-10-01, £0.00 | → | tax year 2025/26, threshold £26,065.00, rate 9%, counted income £30,000.00, repayment £354.00, deducted through paye £0.00, balance £354.00 |
| unearned income of exactly £2,000 is left out | £30,000.00, £2,000.00, plan-2, 2025-10-01, £0.00 | → | tax year 2025/26, threshold £28,470.00, rate 9%, counted income £30,000.00, repayment £137.00, deducted through paye £0.00, balance £137.00 |
| a penny more and all of it counts | £30,000.00, £2,000.01, plan-2, 2025-10-01, £0.00 | → | tax year 2025/26, threshold £28,470.00, rate 9%, counted income £32,000.01, repayment £317.00, deducted through paye £0.00, balance £317.00 |
| income below the threshold repays nothing, and PAYE deductions are owed back | £20,000.00, £0.00, plan-2, 2025-10-01, £50.00 | → | tax year 2025/26, threshold £28,470.00, rate 9%, counted income £20,000.00, repayment £0.00, deducted through paye £50.00, balance -£50.00 |
| income exactly at the threshold repays nothing | £28,470.00, £0.00, plan-2, 2025-10-01, £0.00 | → | tax year 2025/26, threshold £28,470.00, rate 9%, counted income £28,470.00, repayment £0.00, deducted through paye £0.00, balance £0.00 |
| plan 5 from 2026/27 | £35,000.00, £0.00, plan-5, 2026-12-31, £0.00 | → | tax year 2026/27, threshold £25,000.00, rate 9%, counted income £35,000.00, repayment £900.00, deducted through paye £0.00, balance £900.00 |
| 5 April 2026 is still 2025/26: plan 4 at £32,745 | £40,000.00, £0.00, plan-4, 2026-04-05, £0.00 | → | tax year 2025/26, threshold £32,745.00, rate 9%, counted income £40,000.00, repayment £652.00, deducted through paye £0.00, balance £652.00 |
Show the other 11 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 6 April 2026 is 2026/27: plan 4 at £33,795 | £40,000.00, £0.00, plan-4, 2026-04-06, £0.00 | → | tax year 2026/27, threshold £33,795.00, rate 9%, counted income £40,000.00, repayment £558.00, deducted through paye £0.00, balance £558.00 |
| 2023/24, plan 2 at £27,295 | £50,000.00, £0.00, plan-2, 2023-06-01, £0.00 | → | tax year 2023/24, threshold £27,295.00, rate 9%, counted income £50,000.00, repayment £2,043.00, deducted through paye £0.00, balance £2,043.00 |
| no income at all | £0.00, £0.00, plan-1, 2026-07-01, £0.00 | → | tax year 2026/27, threshold £26,900.00, rate 9%, counted income £0.00, repayment £0.00, deducted through paye £0.00, balance £0.00 |
| plan 5 before 2026/27 is an error | £35,000.00, £0.00, plan-5, 2025-10-01, £0.00 | → | error: no student loan threshold for plan-5 in tax year 2025/26 |
| a tax year before the data is an error | £35,000.00, £0.00, plan-1, 2022-10-01, £0.00 | → | error: no student loan threshold for plan-1 in tax year 2022/23 |
| an unknown plan is an error | £35,000.00, £0.00, plan-3, 2025-10-01, £0.00 | → | error: unknown student loan plan "plan-3" |
| income in euros is an error | €35,000.00, £0.00, plan-1, 2025-10-01, £0.00 | → | error: earnedIncome must be in GBP, received EUR |
| negative earned income is an error | -£0.01, £0.00, plan-1, 2025-10-01, £0.00 | → | error: earnedIncome must not be negative, received -1 |
| negative unearned income is an error | £0.00, -£0.01, plan-1, 2025-10-01, £0.00 | → | error: unearnedIncome must not be negative, received -1 |
| a negative PAYE deduction is an error | £0.00, £0.00, plan-1, 2025-10-01, -£0.01 | → | error: deductedThroughPaye must not be negative, received -1 |
| an impossible date is an error | £0.00, £0.00, plan-1, 2025-02-30, £0.00 | → | error: "2025-02-30" is not a real calendar date |
More from the author
The thresholds and rates are not repeated here. They are the dated rows of `payroll.student-loan` (tax years 2023/24 to 2026/27), imported from its data module, so the two capabilities can never disagree about a threshold. That one answers "what does the employer deduct from this payslip"; this one answers "what is due for the year". Plan 5 has no threshold before 2026/27, and a tax year the data does not reach is an error, not a guess.
## Decisions
- **Unearned income is all or nothing.** Up to £2,000 in the year it is left out entirely; over £2,000, all of it counts (reg. 29: "excluding unearned income unless the amount of such income for that year exceeds £2,000"). Exactly £2,000 is left out. The £2,000 is a dated row in `data/unearned-income-limits.json`. - **Whole pounds, rounded down**, as HMRC rounds student loan repayments. - **The tax year is picked by any date in it**, through `dates.uk-tax-year`: 5 April 2026 is 2025/26 and 6 April 2026 is 2026/27. - **One plan per call.** A postgraduate loan is repaid alongside a plan 1, 2, 4 or 5 loan, each against its own threshold: call once for each. With two undergraduate plans the lowest threshold applies; choosing it is the caller's job. - `balance` can be negative: PAYE deducted more than the year's income justifies (irregular pay, a bonus month), which the Student Loans Company refunds. - Income is what the caller says it is: working out "total income" (pension contributions and losses deducted, some benefits excluded) is the tax return's job, not this function's.
## Sources
- The Education (Student Loans) (Repayment) Regulations 2009, reg. 29 (9% and 6%, the threshold, the £2,000 unearned income rule): https://www.legislation.gov.uk/uksi/2009/470/regulation/29 - GOV.UK, "Repaying your student loan: what you pay" (thresholds; the worked example of a plan 1 borrower on £42,000 repaying £1,359, pinned as a vector): https://www.gov.uk/repaying-your-student-loan/what-you-pay - HMRC, Collection of Student Loans Manual CSLM18025, "rounding down": https://www.gov.uk/hmrc-internal-manuals/collection-of-student-loans-manual/cslm18025 - Thresholds: see payroll.student-loan.
## Notices
Contains public sector information licensed under the Open Government Licence v3.0 (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).
Legislation: Crown copyright and database right.
1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 245 |
| README.md | 2,870 |
| data/unearned-income-limits.json | 232 |
| impl/python.py | 3,824 |
| impl/rust.rs | 4,759 |
| impl/typescript.ts | 3,726 |
| vectors.json | 11,335 |