Functional Weave
Code in TypeScript

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

  • ossVat(€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
  • ossVat(€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
  • ossVat(€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.

export function ossVat(net: Money, sellerCountry: string, customerCountry: string, euSalesPreviousYear: Money, euSalesThisYear: Money, optedIn: boolean, onDate: string): OssVat
netMoneythis supply's price before VAT, in any currency
sellerCountrystringISO 3166-1 alpha-2 where the seller is established; a non-EU code for sellers outside the EU
customerCountrystringthe EU member state the goods go to (or the consumer is in); EL is accepted for Greece
euSalesPreviousYearMoneylast calendar year's cross-border B2C distance sales and TBE services, net, in EUR
euSalesThisYearMoneythis calendar year's, including this supply, net, in EUR
optedInboolthe seller has opted to tax at destination below the threshold
onDatedatethe date of supply
returnsOssVat

The type it declares, generated into your project

/** Where VAT is due, at what rate, and how much. */
export interface OssVat {
  /** the member state whose VAT applies */
  readonly taxCountry: 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) */
  readonly scheme: string;
  /** that state's standard rate */
  readonly basisPoints: number;
  /** the EUR 10,000 threshold is exceeded this year or was last year */
  readonly thresholdExceeded: boolean;
  readonly net: Money;
  /** net at the rate, rounded half up */
  readonly vat: Money;
  readonly gross: Money;
}

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

import { ossVat } from "#fune/retail.oss-vat@^1";
impl/typescript.ts · 98 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.

import { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { applyRate } from "./money_apply_rate.ts";  ← from money.apply-rate ^1.0.0 · built alongside by fune
import { OSS_RULES, OSS_RULES_HISTORY } from "./retail_oss_vat_data.ts";  ← this capability’s own data, compiled from data/oss-rules.json into the same file by fune build
import { type OssVat } from "./retail_oss_vat_types.ts";

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const COUNTRY = /^[A-Z]{2}$/;

// The EU writes Greece as EL in VAT matters; ISO 3166 says GR. Accept both.
function country(code: string, name: string): string {
  if (!COUNTRY.test(code)) throw new RangeError(`${name} must be an ISO 3166-1 alpha-2 code, received "${code}"`);
  return code === "EL" ? "GR" : code;
}

function isMember(code: string): boolean {
  return OSS_RULES.some((r) => r.rule === "standard-rate" && r.country === code);
}

function lookup(rule: string, where: string, onDate: string): number {
  let best: { validFrom: string; value: number } | null = null;
  for (const r of OSS_RULES) {
    if (r.rule !== rule || r.country !== where) continue;
    if (onDate < r.validFrom || (r.validTo !== null && onDate > r.validTo)) continue;
    if (best === null || r.validFrom > best.validFrom) best = { validFrom: r.validFrom, value: r.value };
  }
  if (best !== null) return best.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 earliest: string | null = null;
    for (const r of OSS_RULES) {
      if (r.rule === rule && r.country === where && (earliest === null || r.validFrom < earliest)) earliest = r.validFrom;
    }
    if (earliest !== null && onDate < earliest) {
      throw new RangeError(
        `no OSS rule "${rule}" for ${where} on ${onDate}: this build was installed with history=${OSS_RULES_HISTORY}, ` +
          `so it only carries rules from ${earliest}. Reinstall with history=full for older supplies.`,
      );
    }
  }
  throw new RangeError(`no OSS rule "${rule}" for ${where} on ${onDate}`);
}

/**
 * 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.
 */
export function ossVat(
  net: Money,
  sellerCountry: string,
  customerCountry: string,
  euSalesPreviousYear: Money,
  euSalesThisYear: Money,
  optedIn: boolean,
  onDate: string,
): OssVat {
  if (!ISO_DATE.test(onDate)) throw new RangeError(`onDate must be an ISO date (YYYY-MM-DD), received "${onDate}"`);
  const seller = country(sellerCountry, "sellerCountry");
  const customer = country(customerCountry, "customerCountry");
  if (!isMember(customer)) throw new RangeError(`customer country "${customerCountry}" is not an EU member state`);
  for (const [name, amount] of [
    ["euSalesPreviousYear", euSalesPreviousYear],
    ["euSalesThisYear", euSalesThisYear],
  ] as const) {
    if (amount.currency !== "EUR") throw new RangeError(`${name} must be in EUR, received ${amount.currency}`);
    if (amount.minor < 0) throw new RangeError(`${name} must not be negative, received ${amount.minor}`);
  }

  const threshold = lookup("distance-selling-threshold", "EU", onDate);
  const thresholdExceeded = euSalesPreviousYear.minor > threshold || euSalesThisYear.minor > threshold;

  let scheme: string;
  let taxCountry: string;
  if (seller === customer) {
    scheme = "domestic";
    taxCountry = customer;
  } else if (isMember(seller) && !thresholdExceeded && !optedIn) {
    scheme = "origin";
    taxCountry = seller;
  } else {
    scheme = "oss";
    taxCountry = customer;
  }

  const basisPoints = lookup("standard-rate", taxCountry, onDate);
  const vat = applyRate(net, basisPoints, "half-up");
  return {
    taxCountry,
    scheme,
    basisPoints,
    thresholdExceeded,
    net,
    vat,
    gross: money(net.minor + vat.minor, net.currency),
  };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript 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. Or pin a range in fune.project and build in one step:

fune add retail.oss-vat
Download for TypeScript retail.oss-vat-1.0.1-typescript.fune · 33,351 bytes sha256 7ff6942cc7364af7e9082be3bbbe4187c9aaa4989956dc0c221ae644db59e933

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

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