payroll.gross-to-net Unreviewed
A UK payslip for one pay period: gross pay, pension, PAYE tax, National Insurance, student loans and net pay.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 8 tests, run in TypeScript, Python and Rust.
Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified payroll specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
Not professional advice. This capability calculates payroll figures from published rules. It is a software component for developers, not tax or legal 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
Status: needs review by a qualified payroll professional before it is published.
One employee's payslip for one pay period. This capability contains no rates, no thresholds and no rounding of its own: each line comes from the capability that owns it, and what is left here is the order of operations, which is where payslips usually go wrong.
For example
gross_to_net(gross £3,000.00, frequency monthly, pay date 2026-04-28, tax code 1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans , pensio…)→ gross £3,000.00, pension employee £0.00, pension employer £0.00, taxable pay £3,000.00, tax £390.20, employee ni £156.16, employer ni £387.45, student loan £0.00, postgraduate loa… 3,000 pounds a month on 1257L, category A, nothing elsegross_to_net(gross £3,000.00, frequency monthly, pay date 2026-04-28, tax code 1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans plan-2, …)→ gross £3,000.00, pension employee £99.20, pension employer £74.40, taxable pay £3,000.00, tax £390.20, employee ni £156.16, employer ni £387.45, student loan £49.00, postgraduate … relief at source pension at the minimums, plan 2 and a postgraduate loangross_to_net(gross £3,000.00, frequency monthly, pay date 2026-04-28, tax code 1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans , pensio…)→ gross £3,000.00, pension employee £150.00, pension employer £90.00, taxable pay £2,850.00, tax £360.20, employee ni £156.16, employer ni £387.45, student loan £0.00, postgraduate … a net pay arrangement comes off before tax but not before NI
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 gross_to_net(input: &PayslipInput) -> Payslip
| input | PayslipInput | |
| returns | Payslip |
The types it declares, generated into your project
/// One employee's pay for one period, and what the payroll knows about them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PayslipInput {
/// gross pay for the period, all of it taxable and NI-able
pub gross: Money,
pub frequency: String,
/// decides the tax year, tax period and every rate
pub pay_date: String,
/// as issued by HMRC, e.g. 1257L, S1257L, BR, 1257L M1
pub tax_code: String,
/// taxable pay already paid this tax year (including a P45's figure)
pub previous_taxable_pay_to_date: Money,
/// tax already deducted this tax year (including a P45's figure)
pub previous_tax_to_date: Money,
/// National Insurance category letter, usually A
pub ni_category: String,
/// every plan the employee repays; empty for none
pub student_loan_plans: Vec<String>,
/// null when the employee is not in a workplace pension
pub pension: Option<PensionScheme>,
}
/// Every line of the payslip, and the year-to-date figures the next period needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Payslip {
pub gross: Money,
/// deducted from pay (net of basic-rate relief under relief at source)
pub pension_employee: Money,
/// paid by the employer on top; not deducted
pub pension_employer: Money,
/// gross less a net pay arrangement pension deduction
pub taxable_pay: Money,
/// PAYE income tax; negative is a refund
pub tax: Money,
pub employee_ni: Money,
/// paid by the employer on top; not deducted
pub employer_ni: Money,
/// undergraduate plans
pub student_loan: Money,
pub postgraduate_loan: Money,
/// what the employee is paid
pub net: Money,
/// previousTaxablePayToDate plus taxablePay
pub taxable_pay_to_date: Money,
/// previousTaxToDate plus tax
pub tax_to_date: Money,
/// tax week or month the payment falls in
pub period: i64,
/// e.g. 2026/27
pub tax_year: String,
}
Your code names it in one line, in the file that uses it
fune!(payroll.gross-to-net@^1); // then call gross_to_net(…)
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::money_amount::{money, money_from_value, money_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
use super::payroll_income_tax::income_tax; ← from payroll.income-tax ^1.0.0 · built alongside by fune
use super::payroll_national_insurance::national_insurance; ← from payroll.national-insurance ^1.0.0 · built alongside by fune
use super::payroll_pension_auto_enrolment::pension_contributions; ← from payroll.pension-auto-enrolment ^1.0.0 · built alongside by fune
use super::payroll_pension_auto_enrolment_types::PensionScheme;
use super::payroll_student_loan::student_loan; ← from payroll.student-loan ^1.0.0 · built alongside by fune
use super::payroll_tax_period::tax_period; ← from payroll.tax-period ^1.0.0 · built alongside by fune
/// One period's payslip, in the order a UK payroll works it out.
///
/// The order matters because each deduction is taken from a different figure:
/// a net pay arrangement pension comes off pay before tax but not before
/// National Insurance; tax is on taxable pay to date; NI and student loans are
/// on this period's gross pay alone; relief at source pension comes off net pay.
///
/// # Panics
/// Panics on anything the capabilities it calls refuse, a non-GBP gross, or a
/// student loan plan listed twice.
pub fn gross_to_net(input: &PayslipInput) -> Payslip {
let gross = &input.gross;
if gross.currency != "GBP" {
panic!(
"payroll amounts must be in GBP, received {} for gross",
gross.currency
);
}
let when = tax_period(&input.pay_date, &input.frequency);
let mut pension_employee = 0i64;
let mut pension_employer = 0i64;
let mut before_tax = 0i64;
if let Some(scheme) = &input.pension {
let pension = pension_contributions(gross, &input.frequency, scheme, &input.pay_date);
pension_employee = pension.employee_deduction.minor;
pension_employer = pension.employer.minor;
if pension.deduct_before_tax {
before_tax = pension.employee_deduction.minor;
}
}
let taxable_pay = money(gross.minor - before_tax, "GBP");
let tax = income_tax(
&input.tax_code,
&input.frequency,
when.period,
&taxable_pay,
&input.previous_taxable_pay_to_date,
&input.previous_tax_to_date,
&input.pay_date,
);
let ni = national_insurance(gross, &input.ni_category, &input.frequency, &input.pay_date, None);
// An employee with more than one undergraduate plan repays once, against
// the lowest threshold, which is the largest of the per-plan deductions; a
// postgraduate loan is repaid alongside it.
let mut undergraduate = 0i64;
let mut postgraduate = 0i64;
let mut seen: Vec<&str> = Vec::new();
for plan in &input.student_loan_plans {
if seen.contains(&plan.as_str()) {
panic!("student loan plan {} is listed twice", plan);
}
seen.push(plan);
let deduction = student_loan(gross, plan, &input.frequency, &input.pay_date).minor;
if plan == "postgraduate" {
postgraduate = deduction;
} else if deduction > undergraduate {
undergraduate = deduction;
}
}
let net = gross.minor - pension_employee - tax.tax.minor - ni.employee.minor - undergraduate - postgraduate;
Payslip {
gross: gross.clone(),
pension_employee: money(pension_employee, "GBP"),
pension_employer: money(pension_employer, "GBP"),
taxable_pay,
tax: tax.tax,
employee_ni: ni.employee,
employer_ni: ni.employer,
student_loan: money(undergraduate, "GBP"),
postgraduate_loan: money(postgraduate, "GBP"),
net: money(net, "GBP"),
taxable_pay_to_date: tax.pay_to_date,
tax_to_date: tax.tax_to_date,
period: when.period,
tax_year: when.tax_year,
}
}
fn optional_int(v: &Value) -> Option<i64> {
if v.is_null() {
None
} else {
Some(v.as_i64())
}
}
pub fn payslip_input_from_value(v: &Value) -> PayslipInput {
let pension = v.get("pension");
PayslipInput {
gross: money_from_value(v.get("gross")),
frequency: v.get("frequency").as_str().to_string(),
pay_date: v.get("payDate").as_str().to_string(),
tax_code: v.get("taxCode").as_str().to_string(),
previous_taxable_pay_to_date: money_from_value(v.get("previousTaxablePayToDate")),
previous_tax_to_date: money_from_value(v.get("previousTaxToDate")),
ni_category: v.get("niCategory").as_str().to_string(),
student_loan_plans: v
.get("studentLoanPlans")
.as_arr()
.iter()
.map(|p| p.as_str().to_string())
.collect(),
pension: if pension.is_null() {
None
} else {
Some(PensionScheme {
employee_basis_points: optional_int(pension.get("employeeBasisPoints")),
employer_basis_points: optional_int(pension.get("employerBasisPoints")),
earnings_basis: pension.get("earningsBasis").as_str().to_string(),
arrangement: pension.get("arrangement").as_str().to_string(),
})
},
}
}
pub fn payslip_to_value(p: &Payslip) -> Value {
Value::obj(vec![
("gross", money_to_value(&p.gross)),
("pensionEmployee", money_to_value(&p.pension_employee)),
("pensionEmployer", money_to_value(&p.pension_employer)),
("taxablePay", money_to_value(&p.taxable_pay)),
("tax", money_to_value(&p.tax)),
("employeeNi", money_to_value(&p.employee_ni)),
("employerNi", money_to_value(&p.employer_ni)),
("studentLoan", money_to_value(&p.student_loan)),
("postgraduateLoan", money_to_value(&p.postgraduate_loan)),
("net", money_to_value(&p.net)),
("taxablePayToDate", money_to_value(&p.taxable_pay_to_date)),
("taxToDate", money_to_value(&p.tax_to_date)),
("period", Value::Int(p.period)),
("taxYear", Value::str(&p.tax_year)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
payslip_to_value(&gross_to_net(&payslip_input_from_value(&args[0])))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 6 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 payroll.gross-to-net
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./payroll.gross-to-net-1.0.1-rust.fune, or fetch it from a terminal with fune pull payroll.gross-to-net@1.0.1:rust.
The whole function, every language, is one file too: payroll.gross-to-net-1.0.1.fune, 31,769 bytes, sha256 c37221261111539c198a5a8f58c94c4736fb0e9603bb5fe8e1d34290fb6a1a56. 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 payroll.gross-to-net
after — your function gets the result and the arguments, and returns the final result.
// fune: after payroll.gross-to-net
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 money.amount in payroll.gross-to-net
// fune: replace payroll.income-tax in payroll.gross-to-net
// fune: replace payroll.national-insurance in payroll.gross-to-net
// fune: replace payroll.pension-auto-enrolment in payroll.gross-to-net
// fune: replace payroll.student-loan in payroll.gross-to-net
// fune: replace payroll.tax-period in payroll.gross-to-net
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 payroll.gross-to-net --steps.
// fune: step payroll.gross-to-net 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 | |
|---|---|---|---|
| 3,000 pounds a month on 1257L, category A, nothing else | gross £3,000.00, frequency monthly, pay date 2026-04-28, tax code 1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans , pensio… | → | gross £3,000.00, pension employee £0.00, pension employer £0.00, taxable pay £3,000.00, tax £390.20, employee ni £156.16, employer ni £387.45, student loan £0.00, postgraduate loa… |
| relief at source pension at the minimums, plan 2 and a postgraduate loan | gross £3,000.00, frequency monthly, pay date 2026-04-28, tax code 1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans plan-2, … | → | gross £3,000.00, pension employee £99.20, pension employer £74.40, taxable pay £3,000.00, tax £390.20, employee ni £156.16, employer ni £387.45, student loan £49.00, postgraduate … |
| a net pay arrangement comes off before tax but not before NI | gross £3,000.00, frequency monthly, pay date 2026-04-28, tax code 1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans , pensio… | → | gross £3,000.00, pension employee £150.00, pension employer £90.00, taxable pay £2,850.00, tax £360.20, employee ni £156.16, employer ni £387.45, student loan £0.00, postgraduate … |
| a Scottish taxpayer with plans 1 and 2 repays once, at plan 1's lower threshold | gross £3,000.00, frequency monthly, pay date 2026-04-28, tax code S1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans plan-2,… | → | gross £3,000.00, pension employee £0.00, pension employer £0.00, taxable pay £3,000.00, tax £392.27, employee ni £156.16, employer ni £387.45, student loan £68.00, postgraduate lo… |
| weekly pay under the allowance and the primary threshold: only employer NI | gross £200.00, frequency weekly, pay date 2025-04-11, tax code 1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans , pension — | → | gross £200.00, pension employee £0.00, pension employer £0.00, taxable pay £200.00, tax £0.00, employee ni £0.00, employer ni £15.60, student loan £0.00, postgraduate loan £0.00, … |
| a student loan plan listed twice is an error | gross £3,000.00, frequency monthly, pay date 2026-04-28, tax code 1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans plan-2, … | → | error: listed twice |
| gross pay in euros is an error | gross €3,000.00, frequency monthly, pay date 2026-04-28, tax code 1257L, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans , pensio… | → | error: must be in GBP |
| a code HMRC does not issue is an error | gross £3,000.00, frequency monthly, pay date 2026-04-28, tax code 1257Q, previous taxable pay to date £0.00, previous tax to date £0.00, ni category A, student loan plans , pensio… | → | error: unrecognised tax code |
More from the author
1. **Tax period** from the pay date and frequency (`payroll.tax-period`). 2. **Pension** (`payroll.pension-auto-enrolment`), if the employee is in a scheme. Under a *net pay arrangement* the employee's contribution comes off pay before tax; under *relief at source* it is taken from net pay, already reduced by basic-rate relief, and taxable pay is untouched. Neither reduces National Insurance or student loan pay: only salary sacrifice does that, and salary sacrifice is a change to gross pay, so give the reduced gross. 3. **Income tax** on taxable pay (`payroll.income-tax`), cumulative or week 1 / month 1 as the code says. 4. **National Insurance** on this period's gross (`payroll.national-insurance`), by the ordinary per-period method. Directors on the annual earnings period method should call `payroll.national-insurance` directly. 5. **Student loans** on this period's gross (`payroll.student-loan`). An employee with more than one undergraduate plan repays once, at the plan with the lowest threshold, which is the largest single-plan deduction; a postgraduate loan is repaid on top. 6. **Net pay** = gross - pension deduction - tax - employee NI - student loans.
A tax refund makes `tax` negative and increases net pay. Employer NI and the employer pension contribution are reported but not deducted.
Out of scope: statutory payments (give them as part of gross), benefits in kind, attachment of earnings orders, salary sacrifice arithmetic, and deductions after net pay.
Sources: the rules are those of the capabilities listed above, each of which cites its own. The order of deductions follows HMRC's "Employer further guide to PAYE and NICs" (CWG2), https://www.gov.uk/government/publications/cwg2-further-guide-to-paye-and-national-insurance-contributions, and for student loans ("when the employee is on more than one plan, start deductions for the plan with the lowest recovery threshold"; loans are worked out on the same pay as secondary Class 1 NICs), HMRC's "Student loan and postgraduate loan repayment guidance for employers", https://www.gov.uk/guidance/special-rules-for-student-loans.
## Before you rely on this
**Not professional advice.** This capability calculates payroll figures from published rules. It is a software component for developers, not tax or legal 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 above, and have a payroll professional review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.
**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified payroll professional has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
## Notices
Contains public sector information licensed under the Open Government Licence v3.0 (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).
1.0.1 marks it unreviewed and adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 183 |
| README.md | 3,834 |
| impl/python.py | 3,098 |
| impl/rust.rs | 5,882 |
| impl/typescript.ts | 3,042 |
| vectors.json | 8,606 |