Functional Weave
Code in Rust

retail.oss-vat

EU distance-selling VAT: which member state's standard rate applies under OSS and the EUR 10,000 threshold, and the VAT.

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

Pinned by 28 tests, run in TypeScript, Python and Rust.

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 VAT specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

VAT on a sale from a business to a consumer in another EU member state: distance sales of goods, and telecommunications, broadcasting and electronic (TBE) services. Since 1 July 2021 these are taxed where the customer is, and declared through the One Stop Shop (OSS), except for small sellers.

## The rule

For example

  • oss_vat(€100.00, DE, FR, €50,000.00, €50,000.00, false, 2026-09-01) → tax country FR, scheme oss, rate 20%, threshold exceeded true, net €100.00, vat €20.00, gross €120.00 over the threshold: the customer's rate through OSS
  • oss_vat(€100.00, DE, FR, €5,000.00, €8,000.00, false, 2026-09-01) → tax country DE, scheme origin, rate 19%, threshold exceeded false, net €100.00, vat €19.00, gross €119.00 under the threshold: the seller's own rate
  • oss_vat(€100.00, DE, FR, €5,000.00, €10,000.00, false, 2026-09-01) → tax country DE, scheme origin, rate 19%, threshold exceeded false, net €100.00, vat €19.00, gross €119.00 exactly EUR 10,000 this year does not exceed it

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 oss_vat(net: &Money, seller_country: &str, customer_country: &str, eu_sales_previous_year: &Money, eu_sales_this_year: &Money, opted_in: bool, on_date: &str) -> OssVat
netMoneythis supply's price before VAT, in any currency
seller_countrystringISO 3166-1 alpha-2 where the seller is established; a non-EU code for sellers outside the EU
customer_countrystringthe EU member state the goods go to (or the consumer is in); EL is accepted for Greece
eu_sales_previous_yearMoneylast calendar year's cross-border B2C distance sales and TBE services, net, in EUR
eu_sales_this_yearMoneythis calendar year's, including this supply, net, in EUR
opted_inboolthe seller has opted to tax at destination below the threshold
on_datedatethe date of supply
returnsOssVat

The type it declares, generated into your project

/// Where VAT is due, at what rate, and how much.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OssVat {
    /// the member state whose VAT applies
    pub tax_country: String,
    /// domestic (same country), origin (below the threshold: the seller's own VAT) or oss (the customer's VAT, declared through the One Stop Shop)
    pub scheme: String,
    /// that state's standard rate
    pub basis_points: i64,
    /// the EUR 10,000 threshold is exceeded this year or was last year
    pub threshold_exceeded: bool,
    pub net: Money,
    /// net at the rate, rounded half up
    pub vat: Money,
    pub gross: Money,
}

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

fune!(retail.oss-vat@^1);  // then call oss_vat(…)
impl/rust.rs · 160 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::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_apply_rate::apply_rate;  ← from money.apply-rate ^1.0.0 · built alongside by fune
use super::retail_oss_vat_data::{OSS_RULES, OSS_RULES_HISTORY};  ← this capability’s own data, compiled from data/oss-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 EU writes Greece as EL in VAT matters; ISO 3166 says GR. Accept both.
fn country(code: &str, name: &str) -> String {
    if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_uppercase()) {
        panic!("{} must be an ISO 3166-1 alpha-2 code, received \"{}\"", name, code);
    }
    if code == "EL" {
        "GR".to_string()
    } else {
        code.to_string()
    }
}

fn is_member(code: &str) -> bool {
    OSS_RULES.iter().any(|r| r.rule == "standard-rate" && r.country == code)
}

fn lookup(rule: &str, place: &str, on_date: &str) -> i64 {
    let mut best: Option<(&str, i64)> = None;
    for r in OSS_RULES {
        if r.rule != rule || r.country != place {
            continue;
        }
        if on_date < r.valid_from {
            continue;
        }
        if let Some(to) = r.valid_to {
            if on_date > to {
                continue;
            }
        }
        if best.map_or(true, |(from, _)| r.valid_from > from) {
            best = Some((r.valid_from, r.value));
        }
    }
    if let Some((_, value)) = best {
        return value;
    }
    // A history=current build carries only the rules still in force; answering
    // an older date with today's rate is the failure the dates exist to prevent.
    if OSS_RULES_HISTORY != "full" {
        let mut earliest: Option<&str> = None;
        for r in OSS_RULES {
            if r.rule == rule && r.country == place && earliest.map_or(true, |e| r.valid_from < e) {
                earliest = Some(r.valid_from);
            }
        }
        if let Some(earliest) = earliest {
            if on_date < earliest {
                panic!(
                    "no OSS rule \"{}\" for {} on {}: this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for older supplies.",
                    rule, place, on_date, OSS_RULES_HISTORY, earliest
                );
            }
        }
    }
    panic!("no OSS rule \"{}\" for {} on {}", rule, place, on_date)
}

/// VAT on a business-to-consumer sale to another EU member state: the
/// seller's own rate while its cross-border sales stay within EUR 10,000 (and
/// it has not opted in), the customer's state's rate through the One Stop
/// Shop once they exceed it, and always the customer's for sellers outside
/// the EU.
///
/// # Panics
/// Panics on a malformed date or country code, a customer outside the EU,
/// threshold totals not in EUR, or a date no rule covers.
pub fn oss_vat(
    net: &Money,
    seller_country: &str,
    customer_country: &str,
    eu_sales_previous_year: &Money,
    eu_sales_this_year: &Money,
    opted_in: bool,
    on_date: &str,
) -> OssVat {
    if !is_iso_date(on_date) {
        panic!("onDate must be an ISO date (YYYY-MM-DD), received \"{}\"", on_date);
    }
    let seller = country(seller_country, "sellerCountry");
    let customer = country(customer_country, "customerCountry");
    if !is_member(&customer) {
        panic!("customer country \"{}\" is not an EU member state", customer_country);
    }
    for (name, amount) in [
        ("euSalesPreviousYear", eu_sales_previous_year),
        ("euSalesThisYear", eu_sales_this_year),
    ] {
        if amount.currency != "EUR" {
            panic!("{} must be in EUR, received {}", name, amount.currency);
        }
        if amount.minor < 0 {
            panic!("{} must not be negative, received {}", name, amount.minor);
        }
    }

    let threshold = lookup("distance-selling-threshold", "EU", on_date);
    let threshold_exceeded =
        eu_sales_previous_year.minor > threshold || eu_sales_this_year.minor > threshold;

    let (scheme, tax_country) = if seller == customer {
        ("domestic", customer)
    } else if is_member(&seller) && !threshold_exceeded && !opted_in {
        ("origin", seller)
    } else {
        ("oss", customer)
    };

    let basis_points = lookup("standard-rate", &tax_country, on_date);
    let vat = apply_rate(net, basis_points, "half-up");
    OssVat {
        tax_country,
        scheme: scheme.to_string(),
        basis_points,
        threshold_exceeded,
        net: net.clone(),
        gross: money(net.minor + vat.minor, &net.currency),
        vat,
    }
}

pub fn oss_vat_to_value(o: &OssVat) -> Value {
    Value::obj(vec![
        ("taxCountry", Value::str(&o.tax_country)),
        ("scheme", Value::str(&o.scheme)),
        ("basisPoints", Value::Int(o.basis_points)),
        ("thresholdExceeded", Value::Bool(o.threshold_exceeded)),
        ("net", money_to_value(&o.net)),
        ("vat", money_to_value(&o.vat)),
        ("gross", money_to_value(&o.gross)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    oss_vat_to_value(&oss_vat(
        &money_from_value(&args[0]),
        args[1].as_str(),
        args[2].as_str(),
        &money_from_value(&args[3]),
        &money_from_value(&args[4]),
        args[5].as_bool(),
        args[6].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 2 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 retail.oss-vat
Download for Rust retail.oss-vat-1.0.1-rust.fune · 34,893 bytes sha256 e7b000323c742a95fd331608a4da0e4a1d64dd5eb9fc0dd136038a6f8153a7e5

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

The whole function, every language, is one file too: retail.oss-vat-1.0.1.fune, 43,282 bytes, sha256 ce85841c8c38c0c8e1ec067733af5d541e14881cde2145cd59fd209bd1cf04ed. 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 retail.oss-vat

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

// fune: after retail.oss-vat

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 retail.oss-vat
// fune: replace money.apply-rate in retail.oss-vat

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 retail.oss-vat --steps.

// fune: step retail.oss-vat 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
over the threshold: the customer's rate through OSS €100.00, DE, FR, €50,000.00, €50,000.00, false, 2026-09-01 → tax country FR, scheme oss, rate 20%, threshold exceeded true, net €100.00, vat €20.00, gross €120.00
under the threshold: the seller's own rate €100.00, DE, FR, €5,000.00, €8,000.00, false, 2026-09-01 → tax country DE, scheme origin, rate 19%, threshold exceeded false, net €100.00, vat €19.00, gross €119.00
exactly EUR 10,000 this year does not exceed it €100.00, DE, FR, €5,000.00, €10,000.00, false, 2026-09-01 → tax country DE, scheme origin, rate 19%, threshold exceeded false, net €100.00, vat €19.00, gross €119.00
one cent over: this supply is already taxed at destination €100.00, DE, FR, €5,000.00, €10,000.01, false, 2026-09-01 → tax country FR, scheme oss, rate 20%, threshold exceeded true, net €100.00, vat €20.00, gross €120.00
exceeded last year only: still destination all this year €100.00, DE, FR, €12,000.00, €0.00, false, 2026-09-01 → tax country FR, scheme oss, rate 20%, threshold exceeded true, net €100.00, vat €20.00, gross €120.00
opting in taxes at destination below the threshold €100.00, DE, FR, €5,000.00, €5,000.00, true, 2026-09-01 → tax country FR, scheme oss, rate 20%, threshold exceeded false, net €100.00, vat €20.00, gross €120.00
same country is a domestic sale €100.00, DE, DE, €50,000.00, €50,000.00, false, 2026-09-01 → tax country DE, scheme domestic, rate 19%, threshold exceeded true, net €100.00, vat €19.00, gross €119.00
a seller outside the EU has no threshold €100.00, GB, IE, €0.00, €0.00, false, 2026-09-01 → tax country IE, scheme oss, rate 23%, threshold exceeded false, net €100.00, vat €23.00, gross €123.00
Estonia on the last day at 22% €100.00, DE, EE, €50,000.00, €50,000.00, false, 2025-06-30 → tax country EE, scheme oss, rate 22%, threshold exceeded true, net €100.00, vat €22.00, gross €122.00
Estonia from 1 July 2025 at 24% €100.00, DE, EE, €50,000.00, €50,000.00, false, 2025-07-01 → tax country EE, scheme oss, rate 24%, threshold exceeded true, net €100.00, vat €24.00, gross €124.00
Show the other 18 tests
CaseArgumentsExpected
Finland's 25.5% rounds half up to the cent €99.99, DE, FI, €50,000.00, €50,000.00, false, 2026-09-01 → tax country FI, scheme oss, rate 25.5%, threshold exceeded true, net €99.99, vat €25.50, gross €125.49
Luxembourg's temporary 16% in 2023 €100.00, DE, LU, €50,000.00, €50,000.00, false, 2023-06-15 → tax country LU, scheme oss, rate 16%, threshold exceeded true, net €100.00, vat €16.00, gross €116.00
Slovakia before and after 1 January 2025: before €100.00, AT, SK, €50,000.00, €50,000.00, false, 2024-12-31 → tax country SK, scheme oss, rate 20%, threshold exceeded true, net €100.00, vat €20.00, gross €120.00
Romania from 1 August 2025 €100.00, AT, RO, €50,000.00, €50,000.00, false, 2025-08-01 → tax country RO, scheme oss, rate 21%, threshold exceeded true, net €100.00, vat €21.00, gross €121.00
EL is accepted for Greece and reported as GR €100.00, IT, EL, €50,000.00, €50,000.00, false, 2026-09-01 → tax country GR, scheme oss, rate 24%, threshold exceeded true, net €100.00, vat €24.00, gross €124.00
the net may be in another currency; the thresholds are in euros 100.00 PLN, PL, CZ, €50,000.00, €50,000.00, false, 2026-09-01 → tax country CZ, scheme oss, rate 21%, threshold exceeded true, net 100.00 PLN, vat 21.00 PLN, gross 121.00 PLN
a credit note: a negative net gives negative VAT -€100.00, DE, FR, €50,000.00, €50,000.00, false, 2026-09-01 → tax country FR, scheme oss, rate 20%, threshold exceeded true, net -€100.00, vat -€20.00, gross -€120.00
Hungary's 27%, the highest standard rate €10.00, DE, HU, €50,000.00, €50,000.00, false, 2026-09-01 → tax country HU, scheme oss, rate 27%, threshold exceeded true, net €10.00, vat €2.70, gross €12.70
a customer outside the EU is an error €100.00, DE, US, €50,000.00, €50,000.00, false, 2026-09-01 → error: customer country "US" is not an EU member state
the UK is outside the EU €100.00, DE, GB, €50,000.00, €50,000.00, false, 2026-09-01 → error: customer country "GB" is not an EU member state
a date before OSS began is an error €100.00, DE, FR, €50,000.00, €50,000.00, false, 2021-06-30 → error: no OSS rule
threshold totals must be in euros €100.00, DE, FR, £5,000.00, €5,000.00, false, 2026-09-01 → error: euSalesPreviousYear must be in EUR, received GBP
a country name is not a code €100.00, Germany, FR, €50,000.00, €50,000.00, false, 2026-09-01 → error: sellerCountry must be an ISO 3166-1 alpha-2 code
a malformed date is an error €100.00, DE, FR, €50,000.00, €50,000.00, false, 01/09/2026 → error: onDate must be an ISO date (YYYY-MM-DD)
a trailing newline is not part of an ISO date (onDate) €100.00, DE, FR, €50,000.00, €50,000.00, false, 2026-09-16 → error: onDate must be an ISO date (YYYY-MM-DD)
non-ASCII digits are not an ISO date (onDate) €100.00, DE, FR, €50,000.00, €50,000.00, false, ٢٠٢٦-09-16 → error: onDate must be an ISO date (YYYY-MM-DD)
a trailing newline is not part of a country code €100.00, DE , FR, €50,000.00, €50,000.00, false, 2026-09-01 → error: sellerCountry must be an ISO 3166-1 alpha-2 code
a trailing newline is not part of a customer country code €100.00, DE, FR , €50,000.00, €50,000.00, false, 2026-09-01 → error: customerCountry must be an ISO 3166-1 alpha-2 code

More from the author

- **Same country** as the seller: a domestic sale at that country's rate (`domestic`). - **Seller established in one EU member state, small:** while its total cross-border B2C distance sales and TBE services to other member states stay at or below EUR 10,000 in the current calendar year, and did in the previous one, it charges its own country's VAT (`origin`). The sale that takes this year's total over EUR 10,000 is already taxed at destination, which is why `euSalesThisYear` includes this supply. A seller may opt to tax at destination anyway (`optedIn`); the option binds for two calendar years. - **Otherwise**, and always for sellers established outside the EU: the customer's member state's VAT (`oss`).

VAT is `net x rate`, rounded half up to the minor unit with `money.apply-rate`. A negative net (a credit note) gives negative VAT.

## Rates are dated data

`data/oss-rules.json` holds each member state's **standard** rate from the start of OSS on 2021-07-01, with each change since on its effective date, and the EUR 10,000 threshold (in euro cents) as a dated row too. Rows that say 2021-07-01 are rates that were already in force then, not their enactment dates. Changes recorded: Luxembourg 17% to 16% for 2023 and back to 17% from 2024; Estonia 20% to 22% from 2024-01-01 and 24% from 2025-07-01; Finland 24% to 25.5% from 2024-09-01; Slovakia 20% to 23% from 2025-01-01; Romania 19% to 21% from 2025-08-01.

A build installed with `history=current` keeps only the rates in force and refuses an older date rather than answering it at today's rate.

## Not modelled

- Reduced, super-reduced and parking rates. Books, food, children's clothes and much else are taxed at reduced rates that differ by country and product; this capability knows only standard rates, so a caller selling reduced-rate goods must look the rate up by product. - Territories outside the EU VAT area (the Canary Islands, whose 7% "VAT" row appears in the Commission's database, Åland, Mount Athos and others) and the reduced rates of Madeira and the Azores. - Imports in consignments up to EUR 150 (the Import One Stop Shop, IOSS), Northern Ireland sellers (XI), and deemed-supplier rules for marketplaces. - Converting sales totals into euros: pass the thresholds in EUR, converted as the member state of identification requires.

## Sources

- Standard rates: European Commission, Taxes in Europe Database (TEDB), queried through its VAT retrieval service (https://ec.europa.eu/taxation_customs/tedb/ws/, WSDL VatRetrievalService.wsdl) on 2026-09-23 for all 27 member states, from 2021-07-01 to 2026-09-23. Human-readable entry point: https://taxation-customs.ec.europa.eu/taxation/vat/vat-directive/vat-rates_en (links to TEDB, https://ec.europa.eu/taxation_customs/tedb/). The database returned semi-annual snapshots; the change dates above are the effective dates of the national measures, each confirmed by the snapshots either side. The latest snapshot the service returned is 2026-07-01 for most states, 2026-01-01 for Cyprus and Portugal and 2025-01-01 for Greece; their rates are assumed unchanged since, and should be re-checked before publishing. - Threshold: Council Directive 2006/112/EC (the VAT Directive), article 59c, as amended by Council Directive (EU) 2017/2455; the application date was moved to 1 July 2021 by Council Decision (EU) 2020/1109. The Commission's "Guide to the VAT One Stop Shop" (section on the EUR 10 000 threshold) states the rule used here: one threshold for TBE services and intra-EU distance sales together, net of VAT, current and preceding calendar year, for suppliers established in only one member state: https://vat-one-stop-shop.ec.europa.eu/system/files/2021-07/OSS_guidelines_en.pdf and the list of legal acts at https://vat-one-stop-shop.ec.europa.eu/vat-e-commerce/eu-legislation_en

1.0.1 fixes Python accepting a trailing newline in sellerCountry and customerCountry, and a trailing newline or non-ASCII digits in onDate; adds tests.

Files

PathBytes
README.md4,378
data/oss-rules.json7,464
impl/python.py4,075
impl/rust.rs5,440
impl/typescript.ts3,975
vectors.json10,796