payroll.tax-code-parse Unreviewed
Parse a UK PAYE tax code (1257L, K475, BR, D0, NT, 0T, S and C prefixes, W1/M1/X) into its allowance and flags.
1.0.2 · published 2026-10-03 by charlie · Anterra
Pinned by 32 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
Takes a UK PAYE tax code apart. It does not calculate tax; `payroll.income-tax` does that, using this.
What a code is made of:
For example
parse_tax_code(1257L)→ code 1257L, region rest-of-uk, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative true the standard personal allowance codeparse_tax_code(S1257L)→ code S1257L, region scotland, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative true a Scottish taxpayerparse_tax_code(C1257L)→ code C1257L, region wales, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative true a Welsh taxpayer
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 parse_tax_code(code: &str) -> TaxCode
| code | string | as HMRC issued it; case and spaces before W1, M1 or X do not matter |
| returns | TaxCode |
The types it declares, generated into your project
// TaxRegion is a string in Rust, one of: "rest-of-uk", "scotland", "wales".
// Parameters take it as &str and results hold it as String.
// TaxCodeKind is a string in Rust, one of: "allowance", "negative-allowance", "basic-rate", "d-rate", "no-tax".
// Parameters take it as &str and results hold it as String.
/// A tax code taken apart.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaxCode {
/// the code without its basis marker, uppercase: S1257L, K475, BR
pub code: String,
/// S prefix is Scotland, C prefix is Wales, none is the rest of the UK
pub region: String,
/// allowance: nnnnL/M/N/T; negative-allowance: K codes; basic-rate: BR; d-rate: D0, D1...; no-tax: NT
pub kind: String,
/// the numeric part: 1257 for 1257L, 475 for K475, the digit of a D code, 0 for BR and NT
pub number: i64,
/// L, M, N or T for allowance codes; null otherwise
pub suffix: Option<String>,
/// tax-free pay a year in pence: (number x 10 + 9) pounds, negative for K codes, 0 for 0T, BR, D and NT
pub annual_allowance: Money,
/// false when the code carries W1, M1 or X (week 1 / month 1 basis)
pub cumulative: bool,
}
Your code names it in one line, in the file that uses it
fune!(payroll.tax-code-parse@^1); // then call parse_tax_code(…)
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_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
/// The whitespace every language trims alike: Rust's trim() also takes \x85,
/// which JavaScript's trim() keeps, and that takes \u{feff}, which this keeps.
fn is_space(c: char) -> bool {
matches!(
c,
' ' | '\t' | '\n' | '\r' | '\u{0b}' | '\u{0c}' | '\u{a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' | '\u{205f}' | '\u{3000}'
)
}
fn all_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
/// A 1-6 digit number with no leading zero (or "0" itself when `zero_ok`).
fn code_number(s: &str, zero_ok: bool) -> Option<i64> {
if !all_digits(s) || s.len() > 6 {
return None;
}
if s.len() > 1 && s.starts_with('0') {
return None;
}
let n: i64 = s.parse().ok()?;
if n == 0 && !zero_ok {
return None;
}
Some(n)
}
/// Take a UK PAYE tax code apart into its region, kind, allowance and basis.
///
/// The annual allowance is number x £10 + £9, not number x £10: a code covers
/// a £10 band of allowances and HMRC's routines give the employee the top of
/// it, so 1257L is worth £12,579 a year to the payroll.
///
/// # Panics
/// Panics on a code HMRC does not issue.
pub fn parse_tax_code(code: &str) -> TaxCode {
let original = code;
let upper = code.trim_matches(is_space).to_uppercase();
if upper.is_empty() {
panic!("unrecognised tax code \"{}\"", original);
}
// The basis marker may be written with or without a space.
let mut cumulative = true;
let mut text: &str = &upper;
for marker in ["W1", "M1", "X"] {
if let Some(rest) = upper.strip_suffix(marker) {
let rest = rest.trim_end_matches(is_space);
if !rest.is_empty() {
text = rest;
cumulative = false;
}
break;
}
}
let (region, body) = if let Some(rest) = text.strip_prefix('S') {
("scotland", rest)
} else if let Some(rest) = text.strip_prefix('C') {
("wales", rest)
} else {
("rest-of-uk", text)
};
let kind: &str;
let mut number = 0i64;
let mut suffix: Option<String> = None;
let mut allowance_pounds = 0i64;
let last = body.chars().last();
if body == "NT" {
if region != "rest-of-uk" {
panic!("there is no Scottish or Welsh NT code: \"{}\"", original);
}
kind = "no-tax";
} else if body == "BR" {
kind = "basic-rate";
} else if body.len() == 2 && body.starts_with('D') && all_digits(&body[1..]) {
kind = "d-rate";
number = body[1..].parse().unwrap();
} else if let Some(n) = body.strip_prefix('K').and_then(|rest| code_number(rest, false)) {
kind = "negative-allowance";
number = n;
allowance_pounds = -(n * 10 + 9);
} else if let Some(n) = match last {
Some('L') | Some('M') | Some('N') | Some('T') => code_number(&body[..body.len() - 1], true),
_ => None,
} {
let letter = last.unwrap();
if n == 0 && letter != 'T' {
panic!(
"unrecognised tax code \"{}\": a zero allowance is only issued as 0T",
original
);
}
kind = "allowance";
number = n;
suffix = Some(letter.to_string());
allowance_pounds = if n == 0 { 0 } else { n * 10 + 9 };
} else {
panic!("unrecognised tax code \"{}\"", original);
}
let prefix = match region {
"scotland" => "S",
"wales" => "C",
_ => "",
};
TaxCode {
code: format!("{}{}", prefix, body),
region: region.to_string(),
kind: kind.to_string(),
number,
suffix,
annual_allowance: money(allowance_pounds * 100, "GBP"),
cumulative,
}
}
pub fn tax_code_to_value(t: &TaxCode) -> Value {
Value::obj(vec![
("code", Value::str(&t.code)),
("region", Value::str(&t.region)),
("kind", Value::str(&t.kind)),
("number", Value::Int(t.number)),
(
"suffix",
match &t.suffix {
Some(s) => Value::str(s),
None => Value::Null,
},
),
("annualAllowance", money_to_value(&t.annual_allowance)),
("cumulative", Value::Bool(t.cumulative)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
tax_code_to_value(&parse_tax_code(args[0].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, 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.tax-code-parse
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./payroll.tax-code-parse-1.0.2-rust.fune, or fetch it from a terminal with fune pull payroll.tax-code-parse@1.0.2:rust.
The whole function, every language, is one file too: payroll.tax-code-parse-1.0.2.fune, 27,637 bytes, sha256 a2d2166d9aec28dc6e7a42f0658b6dcc1f5ffc41287b0e4880b0b47a9860d3db. 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.tax-code-parse
after — your function gets the result and the arguments, and returns the final result.
// fune: after payroll.tax-code-parse
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.tax-code-parse
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.tax-code-parse --steps.
// fune: step payroll.tax-code-parse 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 | |
|---|---|---|---|
| the standard personal allowance code | 1257L | → | code 1257L, region rest-of-uk, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative true |
| a Scottish taxpayer | S1257L | → | code S1257L, region scotland, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative true |
| a Welsh taxpayer | C1257L | → | code C1257L, region wales, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative true |
| lower case and stray spaces are tidied | s1257l | → | code S1257L, region scotland, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative true |
| emergency code on a week 1 basis, with a space | 1257L W1 | → | code 1257L, region rest-of-uk, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative false |
| month 1 basis written without a space | 1257LM1 | → | code 1257L, region rest-of-uk, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative false |
| the X marker is also non-cumulative | 1257L X | → | code 1257L, region rest-of-uk, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative false |
| marriage allowance recipient suffix M | 1383M | → | code 1383M, region rest-of-uk, kind allowance, number 1,383, suffix M, annual allowance £13,839.00, cumulative true |
| 0T is no allowance at all, not 9 pounds | 0T | → | code 0T, region rest-of-uk, kind allowance, number 0, suffix T, annual allowance £0.00, cumulative true |
| code 1 is worth 19 pounds | 1L | → | code 1L, region rest-of-uk, kind allowance, number 1, suffix L, annual allowance £19.00, cumulative true |
Show the other 22 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a K code is a negative allowance | K475 | → | code K475, region rest-of-uk, kind negative-allowance, number 475, suffix —, annual allowance -£4,759.00, cumulative true |
| a Scottish K code on a month 1 basis | SK475 M1 | → | code SK475, region scotland, kind negative-allowance, number 475, suffix —, annual allowance -£4,759.00, cumulative false |
| BR taxes everything at the basic rate | BR | → | code BR, region rest-of-uk, kind basic-rate, number 0, suffix —, annual allowance £0.00, cumulative true |
| Welsh BR on a week 1 basis | CBR W1 | → | code CBR, region wales, kind basic-rate, number 0, suffix —, annual allowance £0.00, cumulative false |
| D0 is the higher rate on everything | D0 | → | code D0, region rest-of-uk, kind d-rate, number 0, suffix —, annual allowance £0.00, cumulative true |
| SD2 is Scotland's advanced rate on everything | SD2 | → | code SD2, region scotland, kind d-rate, number 2, suffix —, annual allowance £0.00, cumulative true |
| NT means no tax | NT | → | code NT, region rest-of-uk, kind no-tax, number 0, suffix —, annual allowance £0.00, cumulative true |
| a six-digit code | 999999T | → | code 999999T, region rest-of-uk, kind allowance, number 999,999, suffix T, annual allowance £9,999,999.00, cumulative true |
| there is no Scottish NT | SNT | → | error: there is no Scottish or Welsh NT code |
| there is no K0 | K0 | → | error: unrecognised tax code |
| a withdrawn suffix is refused | 1257P | → | error: unrecognised tax code |
| a number without a suffix is refused | 1257 | → | error: unrecognised tax code |
| 0L is not issued | 0L | → | error: only issued as 0T |
| leading zeros are refused | 01257L | → | error: unrecognised tax code |
| an empty code is refused | → | error: unrecognised tax code | |
| a basis marker on its own is not a code | W1 | → | error: unrecognised tax code |
| a trailing newline is trimmed like any other space | 1257L | → | code 1257L, region rest-of-uk, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative true |
| a newline before the basis marker is a space | 1257L W1 | → | code 1257L, region rest-of-uk, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative false |
| a no-break space is trimmed in every language | 1257L | → | code 1257L, region rest-of-uk, kind allowance, number 1,257, suffix L, annual allowance £12,579.00, cumulative true |
| a next-line control (U+0085) is not trimmed, so the code is refused | 1257L | → | error: unrecognised tax code |
| a unit separator (U+001F) is not trimmed, so the code is refused | 1257L | → | error: unrecognised tax code |
| a byte order mark (U+FEFF) is not trimmed, so the code is refused | 1257L | → | error: unrecognised tax code |
More from the author
- An optional country prefix: `S` for a Scottish taxpayer, `C` for a Welsh one, nothing for England and Northern Ireland (`rest-of-uk`). - The code proper, one of: - a number and a suffix letter, `1257L`, `1100M`, `1385N`, `0T`: a tax-free allowance. The number is the allowance divided by ten, so the annual allowance HMRC's routines use is **number x £10 + £9**: 1257L is £12,579, not £12,570, because the code covers a £10 range and the routines give the employee the top of it. `0T` means no allowance at all. - `K` and a number, `K475`: a negative allowance, pay to be *added* to taxable pay (£4,759 a year for K475). There is no K0. - `BR`: all pay at the basic rate. `D0`, `D1` (and `SD0` to `SD3` in Scotland): all pay at the rate one, two, three or four bands above basic. - `NT`: no tax. There is no Scottish or Welsh NT, so `SNT` is refused. - An optional basis marker, `W1`, `M1` or `X`, meaning the code is operated on a week 1 / month 1 (non-cumulative) basis. It may follow the code with or without a space: `1257L W1`, `1257LM1`, `1257L X`.
Suffix letters L, M, N and T do not change the calculation (M and N are the marriage allowance, T means HMRC wants to review the code); they are returned so a payroll can apply HMRC's bulk code uplifts, which are made by suffix. Withdrawn suffixes (P, V, Y, retired in 2016) and anything else unrecognised are errors rather than guesses. Leading zeros (`01257L`) are refused, since HMRC never issues them and they usually mean a keying error.
Sources: HMRC, "Tax codes" (https://www.gov.uk/tax-codes, and its "What your tax code means" page, https://www.gov.uk/tax-codes/what-your-tax-code-means); HMRC, "Specification for PAYE tax table routines" v24.0 (February 2026), paragraphs 4.3.1 (the £10 x number + £9 annual value, K codes, 0T), 7 and 11 (NT has no S or C version), 8 (week 1 / month 1 basis), https://www.gov.uk/government/publications/payroll-technical-specifications-income-tax.
1.0.1 fixes Python accepting a trailing newline in code (its patterns now use fullmatch), and trims the same whitespace in every language: spaces, tabs, line breaks and Unicode space separators. Before, Python also trimmed U+001C to U+001F and U+0085, Rust U+0085 and TypeScript U+FEFF; each is now refused as part of the code. Adds tests.
## 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 specialist 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 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.
## 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.2 marks it unreviewed and adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 185 |
| README.md | 3,804 |
| impl/python.py | 3,063 |
| impl/rust.rs | 4,541 |
| impl/typescript.ts | 3,047 |
| vectors.json | 7,757 |