dates.uk-tax-year
The UK personal tax year containing a date: 6 April to 5 April, with HMRC's label.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
The UK personal tax year runs 6 April to 5 April inclusive - a survival of the 1752 switch to the Gregorian calendar, when eleven days were dropped and the Treasury moved year end rather than lose the revenue.
The 6 April boundary is where implementations go wrong, so the vectors pin both sides of it. A test written as month >= 4 puts 1-5 April in the wrong tax year. A test written against 1 April is testing the company financial year end, which is a different thing that happens to be five days earlier, and mixing the two moves everybody's year by a working week.
For example
uk_tax_year(2026-04-06)→ start 2026-04-06, end 2027-04-05, label 2026/27 6 April is the first day of the new tax yearuk_tax_year(2026-04-05)→ start 2025-04-06, end 2026-04-05, label 2025/26 5 April is the last day of the old oneuk_tax_year(2026-04-04)→ start 2025-04-06, end 2026-04-05, label 2025/26 4 April is still the old tax year
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 uk_tax_year(iso: &str) -> UkTaxYear
| iso | date | any date inside the tax year you want |
| returns | UkTaxYear |
The type it declares, generated into your project
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UkTaxYear {
/// 6 April, the first day of the tax year
pub start: String,
/// 5 April of the following calendar year, the last day; inclusive
pub end: String,
/// HMRC's own form: 2026/27
pub label: String,
}
Your code names it in one line, in the file that uses it
fune!(dates.uk-tax-year@^1); // then call uk_tax_year(…)
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_add_days::{format_iso_date, parse_iso_date, CivilDate}; ← from dates.add-days ^1.0.0 · built alongside by fune
/// The UK personal tax year begins on 6 April, a leftover of the 1752 calendar
/// reform that nobody has been willing to move since.
const TAX_YEAR_START_MONTH: i64 = 4;
const TAX_YEAR_START_DAY: i64 = 6;
/// The UK personal tax year containing a date: 6 April to 5 April inclusive.
///
/// The boundary is where this gets implemented wrong. 5 April 2026 belongs to
/// 2025/26 and 6 April 2026 belongs to 2026/27, so a comparison written as
/// `month >= 4` puts the whole of 1-5 April in the wrong year, and one written
/// against 1 April - the company financial year end, a different thing entirely
/// - moves the boundary by five days for everybody.
///
/// # Panics
/// Panics on a malformed or impossible date, or one whose tax year would fall
/// outside the supported range.
pub fn uk_tax_year(iso: &str) -> UkTaxYear {
let date = parse_iso_date(iso);
// Compare (month, day) against (4, 6) as a pair. On or after 6 April the
// date is in the tax year that started this calendar year; before it, the
// date still belongs to the one that started last April.
let on_or_after_start = (date.month, date.day) >= (TAX_YEAR_START_MONTH, TAX_YEAR_START_DAY);
let start_year = if on_or_after_start {
date.year
} else {
date.year - 1
};
if start_year < 1 || start_year + 1 > 9999 {
panic!(
"\"{}\" is outside the supported range 0001-01-01 to 9999-12-31",
iso
);
}
UkTaxYear {
start: format_iso_date(&CivilDate {
year: start_year,
month: TAX_YEAR_START_MONTH,
day: TAX_YEAR_START_DAY,
}),
end: format_iso_date(&CivilDate {
year: start_year + 1,
month: TAX_YEAR_START_MONTH,
day: TAX_YEAR_START_DAY - 1,
}),
label: tax_year_label(start_year),
}
}
/// Just the label, for report headings: "2026/27".
pub fn uk_tax_year_label(iso: &str) -> String {
uk_tax_year(iso).label
}
fn tax_year_label(start_year: i64) -> String {
// The second half is two digits of the end year, zero-padded, so 1999/00 and
// 2000/01 come out right rather than as 1999/0 or 1999/2000.
format!("{:04}/{:02}", start_year, (start_year + 1) % 100)
}
pub fn uk_tax_year_to_value(year: &UkTaxYear) -> Value {
Value::obj(vec![
("start", Value::str(&year.start)),
("end", Value::str(&year.end)),
("label", Value::str(&year.label)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
uk_tax_year_to_value(&uk_tax_year(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 dates.uk-tax-year
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./dates.uk-tax-year-1.0.0-rust.fune, or fetch it from a terminal with fune pull dates.uk-tax-year@1.0.0:rust.
The whole function, every language, is one file too: dates.uk-tax-year-1.0.0.fune, 13,524 bytes, sha256 48aafe74e712674b2fa094385a252387c41bace1a20db75a8603362dc9902d82. 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 dates.uk-tax-year
after — your function gets the result and the arguments, and returns the final result.
// fune: after dates.uk-tax-year
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.add-days in dates.uk-tax-year
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 dates.uk-tax-year --steps.
// fune: step dates.uk-tax-year 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 | |
|---|---|---|---|
| 6 April is the first day of the new tax year | 2026-04-06 | → | start 2026-04-06, end 2027-04-05, label 2026/27 |
| 5 April is the last day of the old one | 2026-04-05 | → | start 2025-04-06, end 2026-04-05, label 2025/26 |
| 4 April is still the old tax year | 2026-04-04 | → | start 2025-04-06, end 2026-04-05, label 2025/26 |
| 7 April is comfortably in the new one | 2026-04-07 | → | start 2026-04-06, end 2027-04-05, label 2026/27 |
| 1 April is the company year end, not the personal tax year | 2026-04-01 | → | start 2025-04-06, end 2026-04-05, label 2025/26 |
| 31 March is in the tax year that ends the following week | 2026-03-31 | → | start 2025-04-06, end 2026-04-05, label 2025/26 |
| January falls in the tax year that started the previous April | 2026-01-01 | → | start 2025-04-06, end 2026-04-05, label 2025/26 |
| New Year's Eve is in the tax year that started that April | 2026-12-31 | → | start 2026-04-06, end 2027-04-05, label 2026/27 |
| a leap day resolves like any other February date | 2024-02-29 | → | start 2023-04-06, end 2024-04-05, label 2023/24 |
| the label rolls over the century correctly | 1999-04-06 | → | start 1999-04-06, end 2000-04-05, label 1999/00 |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the day before that century rollover | 1999-04-05 | → | start 1998-04-06, end 1999-04-05, label 1998/99 |
| the year 2000 label is 2000/01, not 2000/1 | 2000-04-06 | → | start 2000-04-06, end 2001-04-05, label 2000/01 |
| a date in the 2000/01 year before its April start | 2001-02-28 | → | start 2000-04-06, end 2001-04-05, label 2000/01 |
| a tax year spanning a leap year ends on 5 April as always | 2024-04-06 | → | start 2024-04-06, end 2025-04-05, label 2024/25 |
| a slash-separated date is an error, not a guess | 06/04/2026 | → | error: is not an ISO date |
| an unpadded day is an error | 2026-04-6 | → | error: is not an ISO date |
| 30 February is not a real date | 2026-02-30 | → | error: is not a real calendar date |
| 31 April is not a real date | 2026-04-31 | → | error: is not a real calendar date |
| a date whose tax year would start before year 1 is out of range | 0001-01-01 | → | error: outside the supported range |
More from the author
"end" is inclusive: 5 April is the last day that belongs to the year, not the first day of the next. That is the opposite convention to dates.business-days-between, which is half-open, and it is deliberate - a tax year is quoted to the public as "to 5 April", and a capability that quietly returned 6 April would be reformatted into an off-by-one on every payslip that printed it.
The label is HMRC's own two-digit form, zero-padded, so 1999/00 and 2000/01 come out right instead of 1999/0.
The rule is one boundary that has not moved since 1800, so it is a constant here rather than a data file. If Parliament ever moves it, that is a dated rules table and a new major version, not an edit to this one.
Files
| Path | Bytes |
|---|---|
| README.md | 1,299 |
| impl/python.py | 2,016 |
| impl/rust.rs | 2,685 |
| impl/typescript.ts | 2,169 |
| vectors.json | 2,956 |