Functional Weave
Code in Rust

education.student-loan-repayment

A graduate's student or postgraduate loan repayment for a tax year, as Self Assessment works it out.

1.0.0 (not the latest) · 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

  • student_loan_repayment(£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,359
  • student_loan_repayment(£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 pay
  • student_loan_repayment(£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.

pub fn student_loan_repayment(earned_income: &Money, unearned_income: &Money, plan: &str, tax_year_date: &str, deducted_through_paye: &Money) -> StudentLoanRepayment
earned_incomeMoneythe year's earned income: pay and self-employed profits, in GBP
unearned_incomeMoneythe year's unearned income: savings interest, dividends, rental profit
planStudentLoanPlanthe one plan or loan to work out; a postgraduate loan alongside another plan needs two calls
tax_year_datedateany date in the tax year: 2026-07-01 means 2026/27
deducted_through_payeMoneywhat employers already deducted for this plan in the year
returnsStudentLoanRepaymentthe 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StudentLoanRepayment {
    /// HMRC's label, 2026/27
    pub tax_year: String,
    /// the plan's annual repayment threshold
    pub threshold: Money,
    /// 900 (9%) for plans 1, 2, 4 and 5; 600 (6%) for the postgraduate loan
    pub basis_points: i64,
    /// earned income, plus unearned income when that is over £2,000
    pub counted_income: Money,
    /// the rate on countedIncome over the threshold, whole pounds rounded down
    pub repayment: Money,
    pub deducted_through_paye: Money,
    /// repayment less PAYE deductions; negative when too much was deducted
    pub balance: Money,
}

Your code names it in one line, in the file that uses it

fune!(education.student-loan-repayment@^1);  // then call student_loan_repayment(…)
impl/rust.rs · 115 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_uk_tax_year::uk_tax_year;  ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
use super::education_student_loan_repayment_data::{
    UNEARNED_INCOME_LIMITS, UNEARNED_INCOME_LIMITS_HISTORY, UNEARNED_INCOME_LIMITS_HORIZON,
};
use super::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::payroll_student_loan_data::{
    STUDENT_LOAN_THRESHOLDS, STUDENT_LOAN_THRESHOLDS_HISTORY, STUDENT_LOAN_THRESHOLDS_HORIZON,
};

fn gbp(value: &Money, name: &str) -> i64 {
    if value.currency != "GBP" {
        panic!("{} must be in GBP, received {}", name, value.currency);
    }
    if value.minor < 0 {
        panic!("{} must not be negative, received {}", name, value.minor);
    }
    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.
///
/// # Panics
/// Panics on a non-GBP or negative amount, an unknown plan, a malformed date,
/// or a tax year the data does not cover.
pub fn student_loan_repayment(
    earned_income: &Money,
    unearned_income: &Money,
    plan: &str,
    tax_year_date: &str,
    deducted_through_paye: &Money,
) -> StudentLoanRepayment {
    let earned = gbp(earned_income, "earnedIncome");
    let unearned = gbp(unearned_income, "unearnedIncome");
    let deducted = gbp(deducted_through_paye, "deductedThroughPaye");
    if !matches!(plan, "plan-1" | "plan-2" | "plan-4" | "plan-5" | "postgraduate") {
        panic!("unknown student loan plan \"{}\"", plan);
    }
    let year = uk_tax_year(tax_year_date);
    let start = year.start.as_str();

    let rule = STUDENT_LOAN_THRESHOLDS
        .iter()
        .find(|r| r.plan == plan && start >= r.valid_from && r.valid_to.map_or(true, |to| start <= to));
    let rule = match rule {
        Some(r) => r,
        None => {
            if let Some(horizon) = STUDENT_LOAN_THRESHOLDS_HORIZON {
                if STUDENT_LOAN_THRESHOLDS_HISTORY != "full" && start < horizon {
                    panic!(
                        "no student loan threshold for {} in tax year {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier tax years.",
                        plan, year.label, STUDENT_LOAN_THRESHOLDS_HISTORY, horizon
                    );
                }
            }
            panic!("no student loan threshold for {} in tax year {}", plan, year.label)
        }
    };
    let limit = UNEARNED_INCOME_LIMITS
        .iter()
        .find(|r| start >= r.valid_from && r.valid_to.map_or(true, |to| start <= to));
    let limit = match limit {
        Some(r) => r,
        None => {
            if let Some(horizon) = UNEARNED_INCOME_LIMITS_HORIZON {
                if UNEARNED_INCOME_LIMITS_HISTORY != "full" && start < horizon {
                    panic!(
                        "no unearned income limit for tax year {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier tax years.",
                        year.label, UNEARNED_INCOME_LIMITS_HISTORY, horizon
                    );
                }
            }
            panic!("no unearned income limit for tax year {}", year.label)
        }
    };

    let counted = earned + if unearned > limit.limit { unearned } else { 0 };
    let excess = counted as i128 - rule.annual_threshold as i128;
    let pounds = if excess <= 0 { 0 } else { (excess * rule.basis_points as i128) / (10000 * 100) };
    let repayment = (pounds * 100) as i64;
    StudentLoanRepayment {
        tax_year: year.label.clone(),
        threshold: money(rule.annual_threshold, "GBP"),
        basis_points: rule.basis_points,
        counted_income: money(counted, "GBP"),
        repayment: money(repayment, "GBP"),
        deducted_through_paye: money(deducted, "GBP"),
        balance: money(repayment - deducted, "GBP"),
    }
}

pub fn student_loan_repayment_to_value(r: &StudentLoanRepayment) -> Value {
    Value::obj(vec![
        ("taxYear", Value::str(&r.tax_year)),
        ("threshold", money_to_value(&r.threshold)),
        ("basisPoints", Value::Int(r.basis_points)),
        ("countedIncome", money_to_value(&r.counted_income)),
        ("repayment", money_to_value(&r.repayment)),
        ("deductedThroughPaye", money_to_value(&r.deducted_through_paye)),
        ("balance", money_to_value(&r.balance)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    student_loan_repayment_to_value(&student_loan_repayment(
        &money_from_value(&args[0]),
        &money_from_value(&args[1]),
        args[2].as_str(),
        args[3].as_str(),
        &money_from_value(&args[4]),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add education.student-loan-repayment
Download for Rust education.student-loan-repayment-1.0.0-rust.fune · 24,985 bytes sha256 d2d52c6d3b160b3dead73468969004e9429c22d65e678ae3c4945e2af18e93ed

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./education.student-loan-repayment-1.0.0-rust.fune, or fetch it from a terminal with fune pull education.student-loan-repayment@1.0.0:rust.

The whole function, every language, is one file too: education.student-loan-repayment-1.0.0.fune, 32,826 bytes, sha256 11414324ad13c828c845addd8ac1ed0e6d2febd0dac6eed4103b7b5daf464376. 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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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.

Files

PathBytes
README.md2,562
data/unearned-income-limits.json232
impl/python.py3,824
impl/rust.rs4,759
impl/typescript.ts3,726
vectors.json11,335