Skip to content

Commit 82b97a0

Browse files
committed
Initial commit.
0 parents  commit 82b97a0

File tree

9 files changed

+389
-0
lines changed

9 files changed

+389
-0
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
*.pyc
2+
/venv
3+
/build
4+
/dist
5+
/*egg*
6+
Makefile
7+
.DS_Store

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# pyvat -- VAT validation for Python

dev-requirements.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pep8
2+
nose
3+
rednose
4+
requests
5+
pycountry

pyvat/__init__.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import re
2+
import pycountry
3+
from .registries import ViesRegistry
4+
5+
6+
WHITESPACE_EXPRESSION = re.compile('[\s\-]+')
7+
"""Whitespace expression.
8+
9+
Used for cleaning VAT numbers.
10+
"""
11+
12+
13+
VAT_NUMBER_EXPRESSIONS = {
14+
'AT': re.compile(r'^\d{9}$'),
15+
'BE': re.compile(r'^\d{10}$'),
16+
'BG': re.compile(r'^\d{9,10}$'),
17+
'CY': re.compile(r'^\d{8}[a-z]$', re.IGNORECASE),
18+
'CZ': re.compile(r'^\d{8,10}$'),
19+
'DE': re.compile(r'^\d{9}$'),
20+
'DK': re.compile(r'^\d{8}$'),
21+
'EE': re.compile(r'^\d{9}$'),
22+
'EL': re.compile(r'^\d{9}$'),
23+
'ES': re.compile(r'^[\da-z]\d{7}[\da-z]$', re.IGNORECASE),
24+
'FI': re.compile(r'^\d{8}$'),
25+
'FR': re.compile(r'^[\da-z]{2}\d{9}$', re.IGNORECASE),
26+
'GB': re.compile(r'^(\d{9})|(\d{12})|(GD\d{3})|(HA\d{3})$', re.IGNORECASE),
27+
'HU': re.compile(r'^\d{8}$'),
28+
'IE': re.compile(r'^\d[\+\*\da-z]\d{5}[a-z]$', re.IGNORECASE),
29+
'IT': re.compile(r'^\d{11}$'),
30+
'LT': re.compile(r'^(\d{9})|(\d{12})$'),
31+
'LU': re.compile(r'^\d{8}$'),
32+
'LV': re.compile(r'^\d{11}$'),
33+
'MT': re.compile(r'^\d{8}$'),
34+
'NL': re.compile(r'^\d{9}B\d{3}$', re.IGNORECASE),
35+
'PL': re.compile(r'^\d{10}$'),
36+
'PT': re.compile(r'^\d{9}$'),
37+
'RO': re.compile(r'^\d{2,10}$'),
38+
'SE': re.compile(r'^\d{12}$'),
39+
'SK': re.compile(r'^\d{10}$'),
40+
}
41+
"""VAT number expressions.
42+
43+
Mapping form ISO 3166-1-alpha-2 country codes to the whitespace-less expression
44+
a valid VAT number from the given country must match excluding the country code
45+
prefix.
46+
47+
EU VAT number structures are retrieved from `VIES
48+
<http://ec.europa.eu/taxation_customs/vies/faqvies.do>`_.
49+
"""
50+
51+
52+
VIES_REGISTRY = ViesRegistry()
53+
"""VIES registry instance.
54+
"""
55+
56+
57+
VAT_REGISTRIES = {
58+
'AT': VIES_REGISTRY,
59+
'BE': VIES_REGISTRY,
60+
'BG': VIES_REGISTRY,
61+
'CY': VIES_REGISTRY,
62+
'CZ': VIES_REGISTRY,
63+
'DE': VIES_REGISTRY,
64+
'DK': VIES_REGISTRY,
65+
'EE': VIES_REGISTRY,
66+
'EL': VIES_REGISTRY,
67+
'ES': VIES_REGISTRY,
68+
'FI': VIES_REGISTRY,
69+
'FR': VIES_REGISTRY,
70+
'GB': VIES_REGISTRY,
71+
'HU': VIES_REGISTRY,
72+
'IE': VIES_REGISTRY,
73+
'IT': VIES_REGISTRY,
74+
'LT': VIES_REGISTRY,
75+
'LU': VIES_REGISTRY,
76+
'LV': VIES_REGISTRY,
77+
'MT': VIES_REGISTRY,
78+
'NL': VIES_REGISTRY,
79+
'PL': VIES_REGISTRY,
80+
'PT': VIES_REGISTRY,
81+
'RO': VIES_REGISTRY,
82+
'SE': VIES_REGISTRY,
83+
'SK': VIES_REGISTRY,
84+
}
85+
"""VAT registries.
86+
87+
Mapping from ISO 3166-1-alpha-2 country codes to the VAT registry capable of
88+
validating the VAT number.
89+
"""
90+
91+
92+
def decompose_vat_number(vat_number,
93+
country_code=None):
94+
"""Decompose a VAT number and an optional country code.
95+
96+
:param vat_number: VAT number.
97+
:param country_code:
98+
Optional country code. Default ``None`` prompting detection from the
99+
VAT number.
100+
:returns:
101+
a :class:`tuple` containing the VAT number and country code or
102+
``(None, None)`` if decomposition failed.
103+
"""
104+
105+
# Clean the VAT number.
106+
vat_number = WHITESPACE_EXPRESSION.sub('', vat_number).upper()
107+
108+
# Attempt to determine the country code of the VAT number if possible.
109+
if not country_code:
110+
country_code = vat_number[0:2]
111+
if not pycountry.countries.get(alpha2=country_code):
112+
return (None, None)
113+
vat_number = vat_number[2:]
114+
elif vat_number[0:2] == country_code:
115+
vat_number = vat_number[2:]
116+
117+
return vat_number, country_code
118+
119+
120+
def is_vat_number_format_valid(vat_number,
121+
country_code=None):
122+
"""Test if the format of a VAT number is valid.
123+
124+
:param vat_number: VAT number to validate.
125+
:param country_code:
126+
Optional country code. Should be supplied if known, as there is no
127+
guarantee that naively entered VAT numbers contain the correct alpha-2
128+
country code prefix for EU countries just as not all non-EU countries
129+
have a reliable country code prefix. Default ``None`` prompting
130+
detection.
131+
:returns:
132+
``True`` if the format of the VAT number can be fully asserted as valid
133+
or ``False`` if not, otherwise ``None`` indicating that the VAT number
134+
format may or may not be valid.
135+
"""
136+
137+
# Decompose the VAT number.
138+
vat_number, country_code = decompose_vat_number(vat_number, country_code)
139+
if not vat_number or not country_code:
140+
return False
141+
142+
# Test the VAT number against an expression if possible.
143+
if not country_code in VAT_NUMBER_EXPRESSIONS:
144+
return None
145+
146+
if not VAT_NUMBER_EXPRESSIONS[country_code].match(vat_number):
147+
return False
148+
149+
return True
150+
151+
152+
def is_vat_number_valid(vat_number,
153+
country_code=None):
154+
"""Test if a VAT number is valid.
155+
156+
If possible, the VAT number will be checked against available registries.
157+
158+
:param vat_number: VAT number to validate.
159+
:param country_code:
160+
Optional country code. Should be supplied if known, as there is no
161+
guarantee that naively entered VAT numbers contain the correct alpha-2
162+
country code prefix for EU countries just as not all non-EU countries
163+
have a reliable country code prefix. Default ``None`` prompting
164+
detection.
165+
:returns:
166+
``True`` if the VAT number can be fully asserted as valid or ``False``
167+
if not, otherwise ``None`` indicating that the VAT number may or may
168+
not be valid.
169+
"""
170+
171+
# Decompose the VAT number.
172+
vat_number, country_code = decompose_vat_number(vat_number, country_code)
173+
if not vat_number or not country_code:
174+
return False
175+
176+
# Test the VAT number format.
177+
format_result = is_vat_number_format_valid(vat_number, country_code)
178+
if format_result is not True:
179+
return format_result
180+
181+
# Attempt to check the VAT number against a registry.
182+
if country_code not in VAT_REGISTRIES:
183+
return None
184+
185+
return VAT_REGISTRIES[country_code].is_vat_number_valid(vat_number,
186+
country_code)
187+
188+
189+
__all__ = ('is_vat_number_format_valid', 'is_vat_number_valid', )

pyvat/registries.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import requests
2+
3+
4+
class Registry(object):
5+
"""Abstract base registry.
6+
7+
Defines an explicit interface for accessing arbitary registries.
8+
"""
9+
10+
def is_vat_number_valid(self, vat_number, country_code):
11+
"""Test if a VAT number is valid according to the registry.
12+
13+
:param vat_number: VAT number without country code prefix.
14+
:param country_code: ISO 3166-1-alpha-2 country code.
15+
:returns: ``True`` if the country code is valid, otherwise ``False``.
16+
"""
17+
18+
raise NotImplementedError()
19+
20+
21+
class ViesRegistry(Registry):
22+
"""VIES registry.
23+
24+
Uses the European Commision's VIES registry for validating VAT numbers.
25+
"""
26+
27+
def is_vat_number_valid(self, vat_number, country_code):
28+
# Request information about the VAT number.
29+
try:
30+
response = requests.post(
31+
'http://ec.europa.eu/taxation_customs/vies/services/'
32+
'checkVatService',
33+
data=(
34+
u'<?xml version="1.0" encoding="UTF-8"?><SOAP-ENV:Envelope'
35+
u' xmlns:ns0="urn:ec.europa.eu:taxud:vies:services:checkVa'
36+
u't:types" xmlns:ns1="http://schemas.xmlsoap.org/soap/enve'
37+
u'lope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-insta'
38+
u'nce" xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/env'
39+
u'elope/"><SOAP-ENV:Header/><ns1:Body><ns0:checkVat><ns0:c'
40+
u'ountryCode>%s</ns0:countryCode><ns0:vatNumber>%s</ns0:va'
41+
u'tNumber></ns0:checkVat></ns1:Body></SOAP-ENV:Envelope>' %
42+
(country_code,
43+
vat_number)
44+
).encode('utf-8'),
45+
headers={
46+
'Content-Type': 'text/xml; charset=utf-8',
47+
}
48+
)
49+
except:
50+
# Do not completely fail problematic requests.
51+
return None
52+
53+
# Do not completely fail problematic requests.
54+
if response.status_code != 200 or not response \
55+
.headers['Content-Type'].startswith('text/xml'):
56+
return None
57+
58+
# This is very rudimentary but also very fast.
59+
return '<valid>true</valid>' in response.text
60+
61+
62+
__all__ = ('Registry', 'ViesRegistry', )

setup.cfg

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[nosetests]
2+
verbosity=2
3+
detailed-errors=1
4+
tests=tests/
5+
rednose=1

setup.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env python
2+
import os
3+
import sys
4+
5+
try:
6+
from setuptools import setup
7+
except ImportError:
8+
from distutils.core import setup
9+
10+
packages = [
11+
'pyvat',
12+
]
13+
14+
requires = [
15+
'requests>=1.0.0,<2.0.0',
16+
]
17+
18+
tests_require = [
19+
'nose',
20+
'rednose',
21+
'pep8',
22+
]
23+
24+
setup(
25+
name='pyvat',
26+
version='1.0.0',
27+
description='VAT validation for Python',
28+
author='Nick Bruun',
29+
author_email='[email protected]',
30+
url='http://bruun.co/',
31+
packages=packages,
32+
package_dir={'pyvat': 'pyvat'},
33+
include_package_data=True,
34+
tests_require=tests_require,
35+
install_requires=requires,
36+
#license=open('LICENSE').read(),
37+
zip_safe=True,
38+
)

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .validators import *

tests/validators.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from pyvat import (is_vat_number_valid, is_vat_number_format_valid)
2+
from unittest import TestCase
3+
4+
5+
class IsVatNumberFormatValidTestCase(TestCase):
6+
"""Test case for :func:`is_vat_number_format_valid`.
7+
"""
8+
9+
def test_dk__no_country_code(self):
10+
"""is_vat_number_format_valid(<DK>, country_code=None)
11+
"""
12+
13+
for vat_number, expected_result in [
14+
('DK 12 34 56 78', True),
15+
('DK12345678', True),
16+
('dk12345678', True),
17+
('DK00000000', True),
18+
('DK99999999', True),
19+
('DK99999O99', False),
20+
('DK9999999', False),
21+
('DK999999900', False),
22+
]:
23+
self.assertEqual(is_vat_number_format_valid(vat_number),
24+
expected_result)
25+
26+
def test_dk__country_code(self):
27+
"""is_vat_number_format_valid(<DK>, country_code='DK')
28+
"""
29+
30+
for vat_number, expected_result in [
31+
('12 34 56 78', True),
32+
('12345678', True),
33+
('12345678', True),
34+
('00000000', True),
35+
('99999999', True),
36+
('99999O99', False),
37+
('9999999', False),
38+
('999999900', False),
39+
]:
40+
self.assertEqual(is_vat_number_format_valid(vat_number,
41+
country_code='DK'),
42+
expected_result)
43+
self.assertEqual(is_vat_number_format_valid('DK%s' % (vat_number),
44+
country_code='DK'),
45+
expected_result)
46+
47+
48+
class IsVatNumberValidTestCase(TestCase):
49+
"""Test case for :func:`is_vat_number_valid`.
50+
"""
51+
52+
def test_dk__no_country_code(self):
53+
"""is_vat_number_valid(<DK>, country_code=None)
54+
"""
55+
56+
for vat_number, expected_result in [
57+
('DK33779437', True),
58+
('DK99999O99', False),
59+
('DK9999999', False),
60+
('DK999999900', False),
61+
]:
62+
self.assertEqual(is_vat_number_valid(vat_number), expected_result)
63+
64+
def test_dk__country_code(self):
65+
"""is_vat_number_valid(<DK>, country_code='DK')
66+
"""
67+
68+
for vat_number, expected_result in [
69+
('33779437', True),
70+
('99999O99', False),
71+
('9999999', False),
72+
('999999900', False),
73+
]:
74+
self.assertEqual(is_vat_number_valid(vat_number,
75+
country_code='DK'),
76+
expected_result)
77+
self.assertEqual(is_vat_number_valid('DK%s' % (vat_number),
78+
country_code='DK'),
79+
expected_result)
80+
81+
__all__ = ('IsVatNumberFormatValidTestCase', 'IsVatNumberValidTestCase', )

0 commit comments

Comments
 (0)