Tools for VAT MOSS and Norway VAT on digital services.
Project description
vat-moss-lite
A Python library for VAT MOSS tasks required of non-EU companies selling software to customers in the EU and Norway. Functionality includes:
- Determining the VAT rate for a customer based on any of the following:
- Billing address
- Declared country of residence
- IP address geolocation via a GeoLite2 database
- Telephone number
- Validation of EU and Norwegian VAT IDs against government web services
- Tools for generating VAT-compliant invoices:
- Fetching European Central Bank exchange rate information
- Configuring exchange rate information for the
moneypackage - Formatting foreign currencies when displaying VAT tax due in national currency
This library has codified all of the standard rate VAT tax rules as of January 2015, and includes code to handle the various VAT exemptions that occur in the EU. This was primarily built to support companies selling software licenses or SaaS. Ebook tax rates may be different - this library does not currently differentiate for those.
Maintenance
vat-moss-lite is a maintained fork of vat_moss, which was
abandoned and incompatible with Python 3.13+ due to its use of the deprecated cgi module. This fork
modernizes the codebase with the following improvements:
- Removes
cgidependency for Python 3.13+ compatibility. - Provides full type annotations.
- Uses a modern Python project structure with
pyproject.toml.
The lite suffix in its name means that:
- As a non-European developer, I do not have sufficient expertise to cover all specific tax rules for every EU country.
- In the future, I aim to remove the built-in HTTP API calls to make this a sans-io library, allowing users to choose their own sync or async HTTP client.
Development tools
- Package management: PDM (used to convert the original setuptools-based layout to
pyproject.toml) - Linting: Ruff
- Type checking: Zuban
- Git hooks: Prek
Resources
In the process of writing this library, I performed quite a bit of research about VAT, VAT MOSS and how to deal with it as a small software company. Hopefully the information below will prove useful to others:
- VAT MOSS Overview - a document discussing the non-code aspects of VAT MOSS, such as general concepts and terms, proof of supply details, exchange rates, invoices, registration and returns
- VAT MOSS Implementation Approach - a document discussing how
I am using this library and
vat-moss.jsto deal with VAT MOSS - vat-moss.js - a port of this library to Javascript. Some features are different primarily due to the fact that the VAT validation webservices do not support cross-domain AJAX requests.
Dependencies
Python 3.12+. No third-party packages required.
Version
0.12.0 - changelog
Installation
pip install vat_moss_lite
Using PDM:
pdm add vat_moss_lite
Using uv:
uv add vat_moss_lite
API
- Determine VAT Rate from Billing Address
- Determine VAT Rate from Declared Residence
- Determine VAT Rate from GeoLite2 Database
- Determine VAT Rate from International Phone Number
- Validate a VAT ID
- Fetch Exchange Rates for Invoices
- Configure money Package Exchange Rates
- Format European Currencies for Invoices
Code examples are written in Python 3.12+.
Determine VAT Rate from Billing Address
The user's VAT Rate can be determined by processing a payment and using the billing address from the payment provider.
The method signature is
vat_moss_lite.billing_address.calculate_rate(country_code, postal_code, city).
This will return a tuple of
(Decimal rate, country code, exception name or None). Examples:
(Decimal('0.0'), 'US', None)(Decimal('0.19'), 'DE', None)(Decimal('0.0'), 'DE', 'Heligoland')
The exception name will be one of the exemptions to the normal VAT rates. See the end of http://ec.europa.eu/taxation_customs/resources/documents/taxation/vat/how_vat_works/rates/vat_rates_en.pdf for a full list.
import vat_moss_lite.billing_address
try:
# Values from payment provider
country_code = 'US'
postal_code = '01950'
city = 'Newburyport'
result = vat_moss_lite.billing_address.calculate_rate(country_code, postal_code, city)
rate, country_code, exception_name = result
# Save place of supply proof
except (ValueError):
# One of the user input values is empty or not a string
For place of supply proof, you should save the country code, postal code, city name, detected rate and any exception name.
Determine VAT Rate from Declared Residence
The user's VAT Rate can be determined by prompting the user with a list of
valid countries obtained from vat_moss_lite.declared_residence.options(). If the
user chooses a country with one or more exceptions, the user should be
presented with another list of "None" and each exception name. This should be
labeled something like: "Special VAT Rate".
The method signature to get the appropriate rate is
vat_moss_lite.declared_residence.calculate_rate(country_code, exception_name).
This will return a tuple of
(Decimal rate, country code, exception name or None). Examples:
(Decimal('0.0'), 'US', None)(Decimal('0.19'), 'DE', None)(Decimal('0.0'), 'DE', 'Heligoland')
The exception name will be one of the exemptions to the normal VAT rates. See the end of http://ec.europa.eu/taxation_customs/resources/documents/taxation/vat/how_vat_works/rates/vat_rates_en.pdf for a full list.
import vat_moss_lite.declared_residence
try:
# Loop through this list of dicts and build a <select> using the 'name' key
# as the text and 'code' key as the value. The 'exceptions' key is a list of
# valid VAT exception names for that country. You will probably need to
# write some JS to show a checkbox if the selected country has exceptions,
# and then present the user with another <select> allowing then to pick
# "None" or one of the exception names.
residence_options = vat_moss_lite.declared_residence.options()
# Values from user input
country_code = 'DE'
exception_name = 'Heligoland'
result = vat_moss_lite.declared_residence.calculate_rate(country_code, exception_name)
rate, country_code, exception_name = result
# Save place of supply proof
except (ValueError):
# One of the user input values is empty or not a string
For place of supply proof, you should save the country code, detected rate and any exception name.
Determine VAT Rate from GeoLite2 Database
The company MaxMind offers a [http://dev.maxmind.com/geoip/geoip2/geolite2/](free geo IP lookup database).
For this you'll need to install something like the nginx module, apache module or one of the various programming language packages.
Personally I like to do it at the web server level since it is fast and always available.
Once you have the data, you need to feed the country code, subdivision name and
city name into the method
vat_moss_lite.geoip2.calculate_rate(country_code, subdivision, city, address_country_code, address_exception).
The subdivision should be the first subdivision name from the GeoLite2
database. The address_country_code and address_exception should be from
vat_moss_lite.billing_address.calculate_rate() or
vat_moss_lite.declared_residence.calculate_rate(). This information is necessary
since some exceptions are city-specific and can't solely be detected by the
user's IP address. This will return a tuple of
(Decimal rate, country code, exception name or None). Examples:
(Decimal('0.0'), 'US', None)(Decimal('0.19'), 'DE', None)(Decimal('0.0'), 'DE', 'Heligoland')
The exception name will be one of the exemptions to the normal VAT rates. See the end of http://ec.europa.eu/taxation_customs/resources/documents/taxation/vat/how_vat_works/rates/vat_rates_en.pdf for a full list.
import vat_moss_lite.geoip2
try:
# Values from web server or API
ip = '8.8.4.4'
country_code = 'US'
subdivision_name = 'Massachusetts'
city_name = 'Newburyport'
# Values from the result of vat_moss_lite.billing_address.calculate_rate() or
# vat_moss_lite.declared_residence.calculate_rate()
address_country_code = 'US'
address_exception = None
result = vat_moss_lite.geoip2.calculate_rate(country_code, subdivision_name, city_name, address_country_code, address_exception)
rate, country_code, exception_name = result
# Save place of supply proof
except (ValueError):
# One of the user input values is empty or not a string
For place of supply proof, you should save the IP address; country code, subdivision name and city name from GeoLite2; the detected rate and any exception name.
Omitting address_country_code and address_exception
If the address_country_code and address_exception are not provided, in some
situations this function will not be able to definitively determine the
VAT rate for the user. This is because some exemptions are for individual
cities, which are only tracked via GeoLite2 at the district level. This sounds
confusing, but if you look at the GeoLite2 data, you'll see some of the city
entries are actually district names. Lame, I know.
In those situations, a vat_moss_lite.errors.UndefinitiveError() exception will be
raised.
Determine VAT Rate from International Phone Number
Prompt the user for their international phone number (with leading +). Once
you have the data, you need to feed the phone number to
vat_moss_lite.phone_number.calculate_rate(phone_number, address_country_code, address_exception).
The address_country_code and address_exception should be from
vat_moss_lite.billing_address.calculate_rate() or
vat_moss_lite.declared_residence.calculate_rate(). This information is necessary
since some exceptions are city-specific and can't solely be detected by the
user's phone number. This will return a tuple of
(Decimal rate, country code, exception name or None). Examples:
(Decimal('0.0'), 'US', None)(Decimal('0.19'), 'DE', None)(Decimal('0.0'), 'DE', 'Heligoland')
The exception name will be one of the exemptions to the normal VAT rates. See the end of http://ec.europa.eu/taxation_customs/resources/documents/taxation/vat/how_vat_works/rates/vat_rates_en.pdf for a full list.
import vat_moss_lite.phone_number
try:
# Values from user
phone_number = '+19785720330'
# Values from the result of vat_moss_lite.billing_address.calculate_rate() or
# vat_moss_lite.declared_residence.calculate_rate()
address_country_code = 'US'
address_exception = None
result = vat_moss_lite.phone_number.calculate_rate(phone_number, address_country_code, address_exception)
rate, country_code, exception_name = result
# Save place of supply proof
except (ValueError):
# One of the user input values is empty or not a string
For place of supply proof, you should save the phone number, detected rate and any exception name.
Omitting address_country_code and address_exception
If the address_country_code and address_exception are not provided, in some
situations this function will not be able to definitively determine the
VAT rate for the user. This is because some exemptions are for individual
cities, which can not be definitely determined by the user's phone number area
code.
In those situations, a vat_moss_lite.errors.UndefinitiveError() exception will be
raised.
Validate a VAT ID
EU businesses do not need to be charged VAT. Instead, under the VAT reverse charge mechanism, you provide them with an invoice listing the price of your digital services, and they are responsible for figuring out the VAT due and paying it, according to their normal accounting practices.
The way to determine if a customer in the EU is a business is to validate their VAT ID.
VAT IDs should contain the two-character country code. See http://en.wikipedia.org/wiki/VAT_identification_number for more info.
The VAT ID can have spaces, dashes or periods within it. Some basic formatting checks are done to prevent expensive HTTP calls to the web services that validate the numbers. However, extensive checksum are not validated. If the format looks fairly correct, it gets sent along to the web server.
import vat_moss_lite.id
import vat_moss_lite.errors
import urllib.error
try:
result = vat_moss_lite.id.validate('GB GD001')
if result:
country_code, normalized_id, company_name = result
# Do your processing to not charge VAT
except (vat_moss_lite.errors.InvalidError):
# Make the user enter a new value
except (vat_moss_lite.errors.WebServiceUnavailableError):
# There was an error processing the request within the VIES service.
#
# Unfortunately this tends to happen a lot with EU countries because the
# VIES service is a proxy for 28 separate member-state APIs.
#
# Tell your customer they have to pay VAT and can recover it
# through appropriate accounting practices.
Fetch Exchange Rates for Invoices
When creating invoices, it is necessary to present the VAT tax amount in the national currency of the country the customer resides in. Since most merchants will be selling in a single currency, it will be often necessary to convert amount into one of the 11 currencies used throughout the EU and Norway.
The exchange rates used for these conversions should come from the European Central Bank. They provide an XML file that is updated on business days between 2:15 and 3:00pm CET.
The vat_moss_lite.exchange_rates.fetch() method will download this XML file and
return a tuple containing the date of rates, as a string in the format
YYYY-MM-DD, and a dict object with the keys being three-character currency
codes and the values being Decimal() objects of the current rates, with the
Euro (EUR) being the base.
Since these rates are only updated once a day, and the fetching of the XML could be subject to latency, the rates should be fetched once a day and cached locally. To prevent introducing lag to visitors of your site, it may make the most sense to use a scheduled job to fetch the rates and cache then. Personally, I fetch the rates daily and store them in a database table for future reference.
import vat_moss_lite.exchange_rates
import urllib.error
try:
date, rates = vat_moss_lite.exchange_rates.fetch()
# Add rates to database table, or other local cache
except (urllib.error.URLError):
# An error occured fetching the rates - requeue the job
Configure money Package Exchange Rates
The money package for Python is a
reasonable choice for working with monetary values. The
vat_moss_lite.exchange_rates submodule includes a function that will use the
exchange rates from the ECB to configure the exchange rates for money.
The first parameter is the base currency, which should always be EUR when
working with the data from the ECB. The second parameter should be a dict with
the keys being three-character currency codes and the values being Decimal()
objects representing the rates.
from decimal import Decimal
from money import Money
import vat_moss_lite.exchange_rates
# Grab the exchange rates from you local cache
rates = {'EUR': Decimal('1.0000'), 'GBP': Decimal('0.77990'),}
vat_moss_lite.exchange_rates.setup_xrates('EUR', rates)
# Now work with your money
amount = Money('10.00', 'USD')
eur_amount = amount.to('EUR')
Format European Currencies for Invoices
With the laws concerning invoices, it is necessary to show at least the VAT tax
due in the national currency of the country where your customer resides. To
help in properly formatting the currency amount for the invoice, the
vat_moss_lite.exchange_rates.format(amount, currency=None) function exists.
This function accepts either a Money object, or a Decimal object plus a
string three-character currency code. It returns the amount formatted using the
local country rules for amounts. For currencies that share symbols, such as the
Danish Krone, Swedish Krona and Norwegian Krone, the symbols are modified by
adding the country initial before kr, as is typical in English writing.
from decimal import Decimal
from money import Money
import vat_moss_lite.exchange_rates
# Using a Money object
amount = Money('4101.79', 'USD')
print(vat_moss_lite.exchange_rates.format(amount))
# Using a decimal and currency code
amount = Decimal('4101.79')
currency = 'USD'
print(vat_moss_lite.exchange_rates.format(amount, currency))
The various output formats that are returned by this function include:
| Currency | Output |
|---|---|
| BGN | 4,101.79 Lev |
| CZK | 4.101,79 Kč |
| DKK | 4.101,79 Dkr |
| EUR | €4.101,79 |
| GBP | £4,101.79 |
| HRK | 4.101,79 Kn |
| HUF | 4.101,79 Ft |
| NOK | 4.101,79 Nkr |
| PLN | 4 101,79 Zł |
| RON | 4.101,79 Lei |
| SEK | 4 101,79 Skr |
| USD | $4,101.79 |
Tests
Almost 500 unit and integration tests are included with this library. Run them with:
pytest tests/
License
MIT License - see the LICENSE file.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file vat_moss_lite-0.12.0.tar.gz.
File metadata
- Download URL: vat_moss_lite-0.12.0.tar.gz
- Upload date:
- Size: 40.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4fd981f0550f354f89a33bbfb44d83a2346aad3803688cfd95d80e209afe0f31
|
|
| MD5 |
4135892c52d12669ada5bc4fc22accb8
|
|
| BLAKE2b-256 |
2f28da792473f0f7575d9c275d868ab5b710a89cdaee024219ddf00fdd6c6c7f
|
Provenance
The following attestation bundles were made for vat_moss_lite-0.12.0.tar.gz:
Publisher:
tests.yml on hongquan/vat-moss-lite
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
vat_moss_lite-0.12.0.tar.gz -
Subject digest:
4fd981f0550f354f89a33bbfb44d83a2346aad3803688cfd95d80e209afe0f31 - Sigstore transparency entry: 1879990665
- Sigstore integration time:
-
Permalink:
hongquan/vat-moss-lite@a0d6a258449de1222b784c0e417bc125fa5fc984 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/hongquan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
tests.yml@a0d6a258449de1222b784c0e417bc125fa5fc984 -
Trigger Event:
push
-
Statement type:
File details
Details for the file vat_moss_lite-0.12.0-py3-none-any.whl.
File metadata
- Download URL: vat_moss_lite-0.12.0-py3-none-any.whl
- Upload date:
- Size: 29.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
30d7204ced1a2d8c63faf40e4cd155cb361f4cbbd742fecf34890fa5eb4b29d2
|
|
| MD5 |
40b0b680c2fe4ce317af59aaaef6e095
|
|
| BLAKE2b-256 |
b45f6dd993738a036d423523551c03aa5fcab3addfa10e41b5f004bd72240b47
|
Provenance
The following attestation bundles were made for vat_moss_lite-0.12.0-py3-none-any.whl:
Publisher:
tests.yml on hongquan/vat-moss-lite
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
vat_moss_lite-0.12.0-py3-none-any.whl -
Subject digest:
30d7204ced1a2d8c63faf40e4cd155cb361f4cbbd742fecf34890fa5eb4b29d2 - Sigstore transparency entry: 1879990951
- Sigstore integration time:
-
Permalink:
hongquan/vat-moss-lite@a0d6a258449de1222b784c0e417bc125fa5fc984 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/hongquan
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
tests.yml@a0d6a258449de1222b784c0e417bc125fa5fc984 -
Trigger Event:
push
-
Statement type: