Specific rules apply for business-to-consumer (B2C) supplies of telecommunications, broadcasting and electronically supplied services (TBE) in the EU. Since 2015, the general rule is that VAT is due in the location where the services are actually used, and EU directives and regulations detail the information that can be used to find out the location (see this overview).
It turns out that, even with the data in hand, it is not that trivial to determine the location of supply and applicable tax in a legally admissible way. This post shows how we do it.
This entry belongs to a series of posts on EU VAT + invoicing requirements and (credit/debit) card payment + fulfillment using Stripe:
- legalities
- initial pricing and data capture
- computing the location of supply
- transactional payment and fulfillment
Territorial scope and rates
VAT rates differ across member states, and within several (and critically all the large ones: Germany, France, UK, Italy, Spain) there are territories where EU VAT does not apply.
The EC publishes the up-to-date, official list of VAT rates and details here (page 13) the territorial scope of VAT).
Here’s the machine-readable (JSON) list of the VAT rates across member state including territorial exceptions. It includes the rates per country, as well as the exceptions, which come in 4 kinds:
- territories where the VAT rate differs
- territories where there is no VAT
- territories that for VAT purposes are treated as belonging to a different state
- territories with no VAT but an alternate local tax
For the latter (places with local tax), there may or may not be a threshold and a MOSS-like office to file tax return. All in all, the easiest way to handle them is probably not to sell there. The largest exceptions are Spain’s Canary Islands (around 2.2 million people) and France’s overseas DROM-COM (close to 2 million people overseas).
The JSON looks like this:
"FR": {
"name": "France",
"code": "FR",
"rate": "0.20",
"exceptions" : {
"French Guiana" : "None",
"Mayotte" : "None",
"DROM-COM" : ["Local", ["TVA", "0.085"]]
}
}
This indicates that the main rate in France is 20%, there’s no VAT in French Guiana/Mayotte, and other DROM-COM have a 8.5% local tax (called “TVA”).
We use a list of regular expressions on the postal codes to detect such special territories, since postal codes are the only location data precise enough – many of these places are tiny!
Beware of third-party lists, including ours – when we originally implemented our VAT/payment system and were looking for machine-readable lists, we bumped into several outdated ones. So take it only as a starting point and make sure you stay up-to-date by reviewing the EC page periodically. Their site allows you to subscribe to get notifications for changes in national VAT rules and rates.
VAT location computation
If you’re under the 100000€ yearly revenue threshold for EU sales, since January 2019 life is fairly easy. Which means the code is simpler in this case. We refer as “EU tax region” to a place in the EU or a non-EU location that because of a territorial exception pays some tax in a EU state in this pseudo-code:
if (!maybe_EU_tax_region(bank_country))
// it's definitely a non-EU country and not subject to any territorial
// exception
return NO_TAX
// Try to find the "EU tax region" for the address. We use the postal code
// since it's the only data precise enough to find the smaller places.
eu_tax_region = eu_tax_region_for_address(address_country, postal_code)
// If the bank country isn't affected by territorial exceptions, we can use
// it right away regardless of the billing address
if(eu_tax_region && !country_has_territorial_exceptions(bank_country))
return TAX(bank_country, tax_for_state(bank_country))
// There are territorial exceptions at play. Check if the bank country
// matches the inferred EU tax region
if(eu_tax_region && tax_country(eu_tax_region) == bank_country)
return TAX(bank_country, tax_for_region(eu_tax_region))
// no match: we know the bank country has got territorial exceptions, but
// are unable to know were exactly service supply is located according to
// EU VAT rules
return I_HAVE_NO_IDEA
It is surprisingly tricky to compute the VAT location in the general case with 2 pieces of non-conflicting evidence. All in all it takes around 200 lines of high-level code (we enjoy the luxury of using a language with algebraic types and pattern matching), and about as much code in the unit test to verify that e.g. no VAT is paid in Heligoland (Germany) or Mount Athos (Greece), or that VAT for Monaco sales goes to France, and likewise for the UK Sovereign Base Area of Akrotiri and Cyprus.
Here’s how it works out. What we want is a check_vat_location
function that
takes at least the IP country and optionally the card (bank) country and the
declared country (billing address) and postal code, and returns when possible
(“I have no idea whatsoever” is a possible outcome)
the VAT location (state) and tax info, which can be:
None
: no tax is dueVAT rate
: VAT due with the member state’s rateVAT_exn rate
: VAT due with a special territorial rateLocal (name, rate)
: there is some local tax with that name and rateLocalUnknown
: there is some local tax to the best of our knowledge, but couldn’t find info on it (we’ll handle bothLocal
andLocalUnknown
the same way, with a message to the user indicating that the purchase cannot be done since we cannot handle taxes)Unknown
: we managed to identify the location, which is excluded from EU VAT, but there might be local tax within that state (handle like LocalUnknown)
We have 3 base functions which perform lookups in our “VAT DB” (essentially the JSON data shown above in deserialized form).
rate_for_country(iso_3166_code)
: returns the rate and country where tax is due. Can returnNone
,Definitely (iso-code, rate)
orMaybe rate
. The later is when we have a guess about the rate but there are territorial exceptions in that country – so we’ll need the postal code to be surerate_for_eu_vat_region(iso_3166_code, territory)
(territory
only has a value when using a territorial exception within a member state): look up in our “VAT DB” and return the rate, which can beUnknown
(not a member state or unable to determine it),VAT rate
,Local (name, rate)
eu_vat_region_of_country_and_postcode(iso_3166_code, postcode)
returns the “EU VAT region” (which, again, can a place in the EU or a non-EU location that because of a territorial exception pays some tax in a EU state) that tries to match the postcode against the territorial exception regexps and returns the country and territory (which can be void) or nothing if this isn’t a EU member state or a special territory (e.g. bona fide non-EU state)
The EU location code is, as said above, surprisingly dense. Here’s the heavily annotated and somewhat adapted (for exposition reasons) main logic for the full 2-piece of evidence case (remember 1 piece of evidence can be used only when our revenue for EU sales is under 100000€) – it’s shorter than trying to describe the algorithm in pseudo-code:
(* in the following code, we use post(al)code and ZIP interchangeably for
* concision *)
let check_vat_location
(* these are self-describing named parameters *)
~ip_country ~card_country ~declared_country ~declared_postal_code () =
(* will be None if there's no declared country or Some (country, zip),
used for easier matching below. Note that the ZIP can be None when not
provided.
*)
let acountry_zip =
CCOpt.map (fun c -> (c, declared_postal_code)) declared_country in
(* whether the card country matches the billing address' *)
let card_eq_address = card_country = CCOpt.map fst acountry_zip in
(* wraps the computed rate and adds the inferred country, returns
None when we don't know anything
Some (country, rate) when we managed to infer something (which might
also be that we found out the country but don't know the tax)
*)
let ret c = function
| `No_idea -> None
| #VAT_db.inferred_vat | `Postcode_needed as x -> Some (c, x)
in
(* pattern match over these 3 values:
* whether the card's country equals the billing address'
* the card country (which may be None if not supplied to this function)
* the billing address' country and ZIP code (might be None)
*)
match card_eq_address, card_country, acountry_zip with
| _, None, None
| true, None, Some _
| true, Some _, None -> None
(* we have 2 matching pieces of evidence: card and address, and were not
given the postal code *)
| true, Some c, Some (_, None) -> begin
match VAT_db.rate_for_country c with
| `Definitely (c, v) -> Some (c, `VAT v)
| `Maybe _ ->
(* we determined the country but it could be affected by a
territorial exception, so indicate that we need the postal
code to be sure
*)
ret c @@ `Postcode_needed
| `None -> ret c @@ `None
end
(* we have 2 matching pieces of evidence: card and address, and were given
the ZIP code *)
| true, Some c, Some (_, Some zip) -> begin
match
VAT_db.eu_vat_region_of_country_and_postcode
~country:c ~postcode:zip ()
with
| None ->
(* no EU VAT or other local taxes *)
ret c `None
| Some region ->
(* it's a EU member state or a special territory, return this *)
ret (VAT_db.country_of_region region) @@
VAT_db.rate_for_eu_vat_region region
end
(* We've only been given the card or the address country.
(with no postal code). Check whether one of them matches
the IP geolocation to get our 2 pieces of evidence.
*)
| false, Some c, None
| false, None, Some (c, None) ->
if c <> ip_country then
(* no match between IP and what we had, will return we have no
idea *)
None
else begin
(* we have a match, but we might still need the postal code *)
match VAT_db.rate_for_country c with
| `Definitely (c, v) -> Some (c, `VAT v)
| `Maybe _ -> ret c `Postcode_needed
| `None -> ret c `None
end
(* We've been given both the card country and the address with postal
code. *)
| false, Some c, Some (c2, Some zip) -> begin
(* Try to find the VAT region *)
match
VAT_db.eu_vat_region_of_country_and_postcode
~country:c2 ~postcode:zip ()
with
| Some region ->
(* check if we have a 2nd piece of evidence for this region,
i.e. if the region's country matches the IP or the card *)
let region_country = VAT_db.country_of_region region in
if region_country = ip_country || region_country = c then
(* we have 2 non-conflicting pieces of evidence, OK *)
ret region_country @@ VAT_db.rate_for_eu_vat_region region
else
None
| None ->
(* the only possibilities to get 2 pieces of evidence are
* IP + card or IP + address
* Therefore, we return None if IP differs from both. *)
if c <> ip_country && c2 <> ip_country then
None
else
(* one of them matched the IP, so return the rate for that
country
*)
match VAT_db.rate_for_country ip_country with
| `Definitely (c, v) -> Some (ip_country, `VAT v)
| `Maybe _ -> ret ip_country `Postcode_needed
| `None -> ret ip_country `None
end
(* We've been given both the card country and the address without postal
code. *)
| false, Some c, Some (c2, None) ->
(* check if all 3 values differ, if so we know literally nothing *)
if c <> ip_country && c2 <> ip_country then
None
else begin
(* ip_country is equal to either card or declared country, so we
* have 2 pieces of evidence and the country is the IP's *)
match VAT_db.rate_for_country ip_country with
| `Definitely (ip_country, v) -> Some (ip_country, `VAT v)
| `Maybe _ -> ret ip_country `Postcode_needed
| `None -> ret ip_country `None
end
(* we have only the IP and billing address to work with *)
| false, None, Some (c, Some zip) ->
(* try to find the region with the billing address *)
match
VAT_db.eu_vat_region_of_country_and_postcode
~country:c ~postcode:zip ()
with
| Some region when VAT_db.country_of_region region = ip_country ->
(* the tax country for the region matches the IP address,
return the rate *)
ret ip_country @@
VAT_db.rate_for_eu_vat_region region
| Some region when c = ip_country ->
(* it's a "EU VAT region" and the billing address matches
the IP *)
ret (VAT_db.country_of_region region) @@
VAT_db.rate_for_eu_vat_region region
| None when c = ip_country ->
(* we have a match and it's not a EU VAT region *)
ret ip_country `None
| None | Some _ ->
(* other cases: no idea *)
None