Functional Weave
Code in Python

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

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

def student_loan_repayment(earned_income: Money, unearned_income: Money, plan: StudentLoanPlan, 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

@dataclass(frozen=True)
class StudentLoanRepayment:
    """One plan's repayment for one tax year."""

    #: HMRC's label, 2026/27
    tax_year: str
    #: the plan's annual repayment threshold
    threshold: Money
    #: 900 (9%) for plans 1, 2, 4 and 5; 600 (6%) for the postgraduate loan
    basis_points: int
    #: earned income, plus unearned income when that is over £2,000
    counted_income: Money
    #: the rate on countedIncome over the threshold, whole pounds rounded down
    repayment: Money
    deducted_through_paye: Money
    #: repayment less PAYE deductions; negative when too much was deducted
    balance: Money

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

from fune.education.student_loan_repayment import student_loan_repayment  # education.student-loan-repayment@^1
impl/python.py · 80 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.

from .dates_uk_tax_year import uk_tax_year  ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
from .education_student_loan_repayment_data import UNEARNED_INCOME_LIMITS, UNEARNED_INCOME_LIMITS_HISTORY, UNEARNED_INCOME_LIMITS_HORIZON  ← this capability’s own data, compiled from data/unearned-income-limits.json into the same file by fune build
from .education_student_loan_repayment_types import StudentLoanRepayment
from .money_amount import Money, money  ← from money.amount ^1.0.0 · built alongside by fune
from .payroll_student_loan_data import STUDENT_LOAN_THRESHOLDS, STUDENT_LOAN_THRESHOLDS_HISTORY, STUDENT_LOAN_THRESHOLDS_HORIZON  ← payroll.student-loan’s rule data (^1.0.0) · built alongside by fune
from .payroll_student_loan_types import StudentLoanPlan

PLANS = ("plan-1", "plan-2", "plan-4", "plan-5", "postgraduate")


def _gbp(value: Money, name: str) -> int:
    if value.currency != "GBP":
        raise ValueError("%s must be in GBP, received %s" % (name, value.currency))
    if isinstance(value.minor, bool) or not isinstance(value.minor, int) or value.minor < 0:
        raise ValueError("%s must not be negative, received %s" % (name, value.minor))
    return value.minor


def student_loan_repayment(
    earned_income: Money,
    unearned_income: Money,
    plan: StudentLoanPlan,
    tax_year_date: str,
    deducted_through_paye: Money,
) -> StudentLoanRepayment:
    """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.
    """
    earned = _gbp(earned_income, "earnedIncome")
    unearned = _gbp(unearned_income, "unearnedIncome")
    deducted = _gbp(deducted_through_paye, "deductedThroughPaye")
    if plan not in PLANS:
        raise ValueError('unknown student loan plan "%s"' % (plan,))
    year = uk_tax_year(tax_year_date)
    start = year.start

    rule = next(
        (
            r
            for r in STUDENT_LOAN_THRESHOLDS
            if r.plan == plan and start >= r.valid_from and (r.valid_to is None or start <= r.valid_to)
        ),
        None,
    )
    if rule is None:
        if STUDENT_LOAN_THRESHOLDS_HISTORY != "full" and STUDENT_LOAN_THRESHOLDS_HORIZON is not None and start < STUDENT_LOAN_THRESHOLDS_HORIZON:
            raise ValueError(
                "no student loan threshold for %s in tax year %s: this build was installed with history=%s, so it only carries rules from %s. "
                "Reinstall with history=full for earlier tax years."
                % (plan, year.label, STUDENT_LOAN_THRESHOLDS_HISTORY, STUDENT_LOAN_THRESHOLDS_HORIZON)
            )
        raise ValueError("no student loan threshold for %s in tax year %s" % (plan, year.label))
    limit = next(
        (r for r in UNEARNED_INCOME_LIMITS if start >= r.valid_from and (r.valid_to is None or start <= r.valid_to)),
        None,
    )
    if limit is None:
        if UNEARNED_INCOME_LIMITS_HISTORY != "full" and UNEARNED_INCOME_LIMITS_HORIZON is not None and start < UNEARNED_INCOME_LIMITS_HORIZON:
            raise ValueError(
                "no unearned income limit for tax year %s: this build was installed with history=%s, so it only carries rules from %s. "
                "Reinstall with history=full for earlier tax years."
                % (year.label, UNEARNED_INCOME_LIMITS_HISTORY, UNEARNED_INCOME_LIMITS_HORIZON)
            )
        raise ValueError("no unearned income limit for tax year %s" % (year.label,))

    counted = earned + (unearned if unearned > limit.limit else 0)
    excess = counted - rule.annual_threshold
    pounds = 0 if excess <= 0 else (excess * rule.basis_points) // (10000 * 100)
    repayment = pounds * 100
    return StudentLoanRepayment(
        tax_year=year.label,
        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"),
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python 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
Download for Python education.student-loan-repayment-1.0.1-python.fune · 24,597 bytes sha256 d0983dd44def1e6662ce086d2ca51ac49a738ba0afbe06ff5a9b9653a650c481

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

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.

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.

## 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

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