Functional Weave
Code in Rust

finance.tax.vat-rate Unreviewed

Look up the VAT rate in force for a jurisdiction, category and date.

1.2.2 · published 2026-10-03 by charlie · Anterra

Pinned by 25 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 chartered accountant or tax adviser 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 tax 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 chartered accountant or tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

The rate is not a constant in the code. It is a dated row in data/vat-rules.json, and the lookup takes the date of supply.

That is the difference between a system that can still reprint a 2009 invoice correctly and one that silently reprints it at today's rate. UK VAT was 15% in 2009, 17.5% in 2010 and 20% since 2011; UK hospitality was 5% for part of the pandemic; and the Irish tourism rate has moved between 9% and 13.5% four times since 2011.

For example

  • vat_rate(GB, STANDARD, 2026-09-16) → jurisdiction GB, category STANDARD, rate 20%, valid from 2011-01-04, valid to — UK standard rate today
  • vat_rate(GB, STANDARD, 2009-06-01) → jurisdiction GB, category STANDARD, rate 15%, valid from 2008-12-01, valid to 2009-12-31 UK standard rate during the 2009 cut
  • vat_rate(GB, STANDARD, 2010-06-01) → jurisdiction GB, category STANDARD, rate 17.5%, valid from 2010-01-01, valid to 2011-01-03 UK standard rate in 2010

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 vat_rate(jurisdiction: &str, category: &str, on_date: &str) -> VatRate
jurisdictionstringISO 3166-1 alpha-2, e.g. GB or IE
categorystringSTANDARD | REDUCED | ZERO | EXEMPT | HOSPITALITY
on_datedateISO date the supply took place
returnsVatRate

The type it declares, generated into your project

/// The rate that applied, and the dates it was in force.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VatRate {
    pub jurisdiction: String,
    pub category: String,
    pub basis_points: i64,
    pub valid_from: String,
    /// null while the rate is still in force
    pub valid_to: Option<String>,
}

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

fune!(finance.tax.vat-rate@^1);  // then call vat_rate(…)
impl/rust.rs · 139 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::finance_tax_vat_rate_data::{VAT_RULES, VAT_RULES_HISTORY};  ← this capability’s own data, compiled from data/vat-rules.json into the same file by fune build

fn is_iso_date(value: &str) -> bool {
    let bytes = value.as_bytes();
    bytes.len() == 10
        && bytes[4] == b'-'
        && bytes[7] == b'-'
        && bytes
            .iter()
            .enumerate()
            .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
}

/// The VAT rate in force for a jurisdiction and category on a given date.
///
/// The date matters. A credit note against a 2009 invoice has to use the rate
/// that applied in 2009, not the one that applies today, and a system that
/// hardcodes 0.20 cannot do that at all.
///
/// ISO dates compare correctly as strings, so this needs no date crate.
///
/// # Panics
/// Panics if the date is malformed, or no rule covers the request.
pub fn vat_rate(jurisdiction: &str, category: &str, on_date: &str) -> VatRate {
    if !is_iso_date(on_date) {
        panic!(
            "onDate must be an ISO date (YYYY-MM-DD), received \"{}\"",
            on_date
        );
    }

    let mut best: Option<VatRate> = None;
    for rule in VAT_RULES {
        if rule.jurisdiction != jurisdiction || rule.category != category {
            continue;
        }
        if on_date < rule.valid_from {
            continue;
        }
        if let Some(valid_to) = rule.valid_to {
            if on_date > valid_to {
                continue;
            }
        }
        let better = match &best {
            None => true,
            Some(current) => rule.valid_from > current.valid_from.as_str(),
        };
        if better {
            best = Some(VatRate {
                jurisdiction: rule.jurisdiction.to_string(),
                category: rule.category.to_string(),
                basis_points: rule.basis_points,
                valid_from: rule.valid_from.to_string(),
                valid_to: rule.valid_to.map(str::to_string),
            });
        }
    }

    match best {
        Some(rate) => rate,
        None => {
            // A build installed with history=current carries only the rules
            // still in force. Answering an older date with today's rate would
            // be the exact failure this capability exists to prevent, so say
            // what happened instead. The horizon that matters is this
            // jurisdiction and category's own earliest surviving rule, not the
            // registry-wide one.
            if VAT_RULES_HISTORY != "full" {
                let mut earliest: Option<&'static str> = None;
                for rule in VAT_RULES {
                    if rule.jurisdiction != jurisdiction || rule.category != category {
                        continue;
                    }
                    if earliest.is_none() || rule.valid_from < earliest.unwrap() {
                        earliest = Some(rule.valid_from);
                    }
                }
                if let Some(earliest) = earliest {
                    if on_date < earliest {
                        panic!(
                            "no VAT rule for {}/{} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full to reprint older invoices.",
                            jurisdiction, category, on_date, VAT_RULES_HISTORY, earliest
                        );
                    }
                }
            }
            panic!(
                "no VAT rule for {}/{} on {}",
                jurisdiction, category, on_date
            )
        }
    }
}

/// Every category this registry knows a rate for, for a jurisdiction and date.
pub fn vat_categories(jurisdiction: &str, on_date: &str) -> Vec<&'static str> {
    let mut seen: Vec<&'static str> = Vec::new();
    for rule in VAT_RULES {
        if rule.jurisdiction != jurisdiction || on_date < rule.valid_from {
            continue;
        }
        if let Some(valid_to) = rule.valid_to {
            if on_date > valid_to {
                continue;
            }
        }
        if !seen.contains(&rule.category) {
            seen.push(rule.category);
        }
    }
    seen.sort_unstable();
    seen
}

pub fn vat_rate_to_value(rate: &VatRate) -> Value {
    Value::obj(vec![
        ("jurisdiction", Value::str(&rate.jurisdiction)),
        ("category", Value::str(&rate.category)),
        ("basisPoints", Value::Int(rate.basis_points)),
        ("validFrom", Value::str(&rate.valid_from)),
        (
            "validTo",
            match &rate.valid_to {
                Some(date) => Value::str(date),
                None => Value::Null,
            },
        ),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    vat_rate_to_value(&vat_rate(
        args[0].as_str(),
        args[1].as_str(),
        args[2].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 nothing else, 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 finance.tax.vat-rate
Download for Rust finance.tax.vat-rate-1.2.2-rust.fune · 22,961 bytes sha256 ab99a29d5c29b0f561bb23f890539017ce837a22b98a5250f6ed3c3b46cd1a24

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./finance.tax.vat-rate-1.2.2-rust.fune, or fetch it from a terminal with fune pull finance.tax.vat-rate@1.2.2:rust.

The whole function, every language, is one file too: finance.tax.vat-rate-1.2.2.fune, 29,231 bytes, sha256 e5a418fbea99dfe246f95af105ed05474df6247aa72affcae9b0c67dabfac0c8. 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 finance.tax.vat-rate

after — your function gets the result and the arguments, and returns the final result.

// fune: after finance.tax.vat-rate

replace — it requires no other capability, so there is no dependency to replace.

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 finance.tax.vat-rate --steps.

// fune: step finance.tax.vat-rate 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
UK standard rate today GB, STANDARD, 2026-09-16 → jurisdiction GB, category STANDARD, rate 20%, valid from 2011-01-04, valid to —
UK standard rate during the 2009 cut GB, STANDARD, 2009-06-01 → jurisdiction GB, category STANDARD, rate 15%, valid from 2008-12-01, valid to 2009-12-31
UK standard rate in 2010 GB, STANDARD, 2010-06-01 → jurisdiction GB, category STANDARD, rate 17.5%, valid from 2010-01-01, valid to 2011-01-03
the day the rate changed GB, STANDARD, 2011-01-04 → jurisdiction GB, category STANDARD, rate 20%, valid from 2011-01-04, valid to —
the day before the rate changed GB, STANDARD, 2011-01-03 → jurisdiction GB, category STANDARD, rate 17.5%, valid from 2010-01-01, valid to 2011-01-03
hospitality during the covid cut GB, HOSPITALITY, 2020-12-25 → jurisdiction GB, category HOSPITALITY, rate 5%, valid from 2020-07-15, valid to 2021-09-30
hospitality during the taper GB, HOSPITALITY, 2021-11-01 → jurisdiction GB, category HOSPITALITY, rate 12.5%, valid from 2021-10-01, valid to 2022-03-31
hospitality back at standard GB, HOSPITALITY, 2026-09-16 → jurisdiction GB, category HOSPITALITY, rate 20%, valid from 2022-04-01, valid to —
UK reduced rate GB, REDUCED, 2026-09-16 → jurisdiction GB, category REDUCED, rate 5%, valid from 1997-09-01, valid to —
UK zero rate GB, ZERO, 2026-09-16 → jurisdiction GB, category ZERO, rate 0%, valid from 1973-04-01, valid to —
Show the other 15 tests
CaseArgumentsExpected
Ireland standard rate IE, STANDARD, 2026-09-16 → jurisdiction IE, category STANDARD, rate 23%, valid from 2021-03-01, valid to —
unknown jurisdiction is an error, not a guess FR, STANDARD, 2026-09-16 → error: no VAT rule
a date before the rule existed is an error GB, STANDARD, 1970-01-01 → error: no VAT rule
a malformed date is an error GB, STANDARD, 16/09/2026 → error: ISO date
Irish hospitality during the 9 percent tourism rate IE, HOSPITALITY, 2015-06-01 → jurisdiction IE, category HOSPITALITY, rate 9%, valid from 2011-07-01, valid to 2018-12-31
Irish hospitality after the 2019 withdrawal IE, HOSPITALITY, 2019-06-01 → jurisdiction IE, category HOSPITALITY, rate 13.5%, valid from 2019-01-01, valid to 2020-10-31
Irish hospitality during the covid 9 percent IE, HOSPITALITY, 2021-06-01 → jurisdiction IE, category HOSPITALITY, rate 9%, valid from 2020-11-01, valid to 2023-08-31
Irish hospitality today IE, HOSPITALITY, 2026-09-16 → jurisdiction IE, category HOSPITALITY, rate 13.5%, valid from 2023-09-01, valid to —
the day the covid rate ended IE, HOSPITALITY, 2023-08-31 → jurisdiction IE, category HOSPITALITY, rate 9%, valid from 2020-11-01, valid to 2023-08-31
Irish standard rate during the 2020-21 cut IE, STANDARD, 2020-12-01 → jurisdiction IE, category STANDARD, rate 21%, valid from 2020-09-01, valid to 2021-02-28
Irish standard rate the day before the cut IE, STANDARD, 2020-08-31 → jurisdiction IE, category STANDARD, rate 23%, valid from 2012-01-01, valid to 2020-08-31
Irish standard rate restored on 1 March 2021 IE, STANDARD, 2021-03-01 → jurisdiction IE, category STANDARD, rate 23%, valid from 2021-03-01, valid to —
a date with a trailing newline is an error GB, STANDARD, 2026-09-16 → error: onDate must be an ISO date (YYYY-MM-DD), received "2026-09-16 "
a date in Arabic-Indic digits is an error GB, STANDARD, ٢٠٢٦-٠٩-١٦ → error: onDate must be an ISO date (YYYY-MM-DD), received "٢٠٢٦-٠٩-١٦"
a malformed date names the parameter as the signature does GB, STANDARD, 16/09/2026 → error: onDate must be an ISO date (YYYY-MM-DD), received "16/09/2026"

More from the author

When a rate changes, publish a new version of this capability's data. Application code does not change at all - that is what 1.1.0 is: 1.0.0 plus the Irish hospitality history, no implementation change. And 1.2.0 is 1.1.0 plus Ireland's temporary 21% standard rate from 1 September 2020 to 28 February 2021, which 1.1.0 missed (source: Revenue, "Current VAT rates", https://www.revenue.ie/en/vat/vat-rates/search-vat-rates/current-vat-rates.aspx).

This version also declares its effective-date columns, so a project can install `history=current` and ship only the rules still in force. A build that does will refuse a date before its horizon rather than answer it with today's rate.

1.2.1 fixes Python accepting a trailing newline or non-ASCII digits in onDate, and names the parameter `onDate` in the malformed-date error in every language (Python and Rust said `on_date`); adds tests.

## Before you rely on this

**Not professional advice.** This capability calculates tax 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 above, and have a chartered accountant or tax adviser 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 chartered accountant or tax adviser 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.2.2 marks it unreviewed and adds its attribution notices (NOTICE). The code and the tests are unchanged.

Files

PathBytes
NOTICE183
README.md2,696
data/vat-rules.json4,180
impl/python.py3,155
impl/rust.rs4,806
impl/typescript.ts2,902
vectors.json6,674