Functional Weave
Code in Python

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.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 24 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.

def 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

@dataclass(frozen=True)
class OssVat:
    """Where VAT is due, at what rate, and how much."""

    #: the member state whose VAT applies
    tax_country: str
    #: domestic (same country), origin (below the threshold: the seller's own VAT) or oss (the customer's VAT, declared through the One Stop Shop)
    scheme: str
    #: that state's standard rate
    basis_points: int
    #: the EUR 10,000 threshold is exceeded this year or was last year
    threshold_exceeded: bool
    net: Money
    #: net at the rate, rounded half up
    vat: Money
    gross: Money

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

from fune.retail.oss_vat import oss_vat  # retail.oss-vat@^1
impl/python.py · 96 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 re

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

_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_COUNTRY = re.compile(r"^[A-Z]{2}$")


def _country(code: str, name: str) -> str:
    # The EU writes Greece as EL in VAT matters; ISO 3166 says GR. Accept both.
    if not isinstance(code, str) or not _COUNTRY.match(code):
        raise ValueError('%s must be an ISO 3166-1 alpha-2 code, received "%s"' % (name, code))
    return "GR" if code == "EL" else code


def _is_member(code: str) -> bool:
    return any(r.rule == "standard-rate" and r.country == code for r in OSS_RULES)


def _lookup(rule: str, where: str, on_date: str) -> int:
    best = None
    for r in OSS_RULES:
        if r.rule != rule or r.country != where:
            continue
        if on_date < r.valid_from or (r.valid_to is not None and on_date > r.valid_to):
            continue
        if best is None or r.valid_from > best.valid_from:
            best = r
    if best is not None:
        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":
        earliest = None
        for r in OSS_RULES:
            if r.rule == rule and r.country == where and (earliest is None or r.valid_from < earliest):
                earliest = r.valid_from
        if earliest is not None and on_date < earliest:
            raise ValueError(
                'no OSS rule "%s" for %s on %s: this build was installed with history=%s, '
                "so it only carries rules from %s. Reinstall with history=full for older supplies."
                % (rule, where, on_date, OSS_RULES_HISTORY, earliest)
            )
    raise ValueError('no OSS rule "%s" for %s on %s' % (rule, where, on_date))


def 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:
    """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.
    """
    if not isinstance(on_date, str) or not _ISO_DATE.match(on_date):
        raise ValueError('onDate must be an ISO date (YYYY-MM-DD), received "%s"' % (on_date,))
    seller = _country(seller_country, "sellerCountry")
    customer = _country(customer_country, "customerCountry")
    if not _is_member(customer):
        raise ValueError('customer country "%s" 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":
            raise ValueError("%s must be in EUR, received %s" % (name, amount.currency))
        if amount.minor < 0:
            raise ValueError("%s must not be negative, received %d" % (name, amount.minor))

    threshold = _lookup("distance-selling-threshold", "EU", on_date)
    threshold_exceeded = eu_sales_previous_year.minor > threshold or eu_sales_this_year.minor > threshold

    if seller == customer:
        scheme, tax_country = "domestic", customer
    elif _is_member(seller) and not threshold_exceeded and not opted_in:
        scheme, tax_country = "origin", seller
    else:
        scheme, tax_country = "oss", customer

    basis_points = _lookup("standard-rate", tax_country, on_date)
    vat = apply_rate(net, basis_points, "half-up")
    return OssVat(
        tax_country=tax_country,
        scheme=scheme,
        basis_points=basis_points,
        threshold_exceeded=threshold_exceeded,
        net=net,
        vat=vat,
        gross=money(net.minor + vat.minor, net.currency),
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python 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 Python retail.oss-vat-1.0.0-python.fune · 31,976 bytes sha256 a8e99a32e9ec67ff57bcb95b281b06942531f4f0ef5e764cf0eab6ddb010d69f

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

The whole function, every language, is one file too: retail.oss-vat-1.0.0.fune, 41,812 bytes, sha256 0db7e57e14bedd5cedc92cb6317fd07a407ca6cd0fed2c85e45e79a207e3849a. 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 14 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)

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

Files

PathBytes
README.md4,225
data/oss-rules.json7,464
impl/python.py4,062
impl/rust.rs5,440
impl/typescript.ts3,975
vectors.json9,634