├── tests ├── testapp │ ├── __init__.py │ ├── urls.py │ ├── models.py │ ├── forms.py │ └── settings.py ├── __init__.py ├── test_validators.py ├── test_types.py └── test_widgets.py ├── .bandit ├── vies ├── forms │ ├── __init__.py │ ├── fields.py │ └── widgets.py ├── __init__.py ├── models.py ├── validators.py ├── locale │ ├── da │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── pl │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── ES │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── nl │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── it │ │ └── LC_MESSAGES │ │ └── django.po └── types.py ├── MANIFEST.in ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── CONTRIBUTING.md ├── .editorconfig ├── LICENSE ├── pyproject.toml └── README.rst /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude: ./tests 3 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | # -*- conding:utf-8 -*- 2 | urlpatterns = [] 3 | -------------------------------------------------------------------------------- /vies/forms/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import * # NoQA 2 | from .widgets import * # NoQA 3 | 4 | __all__ = ["VATINField", "VATINWidget"] # NoQA 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include vies/locale/*/LC_MESSAGES/django.mo 4 | exclude .* 5 | exclude CONTRIBUTING.md 6 | prune .github 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | VALID_VIES = "LU26375245" 2 | VALID_VIES_COUNTRY_CODE = "LU" 3 | VALID_VIES_NUMBER = "26375245" 4 | VALID_VIES_IE = [ 5 | "1234567X", 6 | "1X23456X", 7 | "1234567XX", 8 | ] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | *.mo 5 | *egg-info* 6 | .Python 7 | bin/* 8 | build/ 9 | .eggs/ 10 | dist/ 11 | include/ 12 | lib/ 13 | docs/_build/ 14 | .coverage 15 | htmlcov/ 16 | _version.py 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | To get started simply clone the repository to your local machine and run: 4 | 5 | ```bash 6 | python setup.py develop 7 | ``` 8 | 9 | To test locally simply run: 10 | ```bash 11 | python setup.py test 12 | ``` 13 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db.models import CharField, Model 2 | 3 | from vies import models 4 | 5 | 6 | class VIESModel(Model): 7 | vat = models.VATINField() 8 | 9 | 10 | class EmptyVIESModel(Model): 11 | name = CharField(default="John Doe", max_length=50) 12 | vat = models.VATINField(blank=True, null=True) 13 | -------------------------------------------------------------------------------- /tests/testapp/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import ModelForm 2 | 3 | from tests.testapp.models import EmptyVIESModel, VIESModel 4 | 5 | 6 | class VIESModelForm(ModelForm): 7 | class Meta: 8 | model = VIESModel 9 | exclude = [] 10 | 11 | 12 | class EmptyVIESModelForm(ModelForm): 13 | class Meta: 14 | model = EmptyVIESModel 15 | exclude = [] 16 | -------------------------------------------------------------------------------- /vies/__init__.py: -------------------------------------------------------------------------------- 1 | """Validate and store VAT Information Exchange System (VIES) data in Django.""" 2 | 3 | import logging 4 | 5 | from . import _version # noqa 6 | 7 | logger = logging.getLogger("vies") 8 | 9 | __version__ = _version.__version__ 10 | VERSION = _version.VERSION_TUPLE 11 | 12 | VIES_WSDL_URL = "https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl" # NoQA 13 | VATIN_MAX_LENGTH = 14 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.py] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | 16 | [*.{rst,ini}] 17 | indent_style = space 18 | indent_size = 4 19 | 20 | [*.{yml,yaml,html,xml,xsl,json,toml}] 21 | indent_style = space 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /vies/models.py: -------------------------------------------------------------------------------- 1 | from django.db.models import CharField 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from . import VATIN_MAX_LENGTH, forms 5 | 6 | 7 | class VATINField(CharField): 8 | """Database field for European VIES VAT Identification Number. 9 | 10 | This field stores and validates VATINs. 11 | 12 | Example: 13 | class MyModel(models.Model): 14 | vat = VATINField(_('EU VAT ID')) 15 | 16 | """ 17 | 18 | description = _("A VIES VAT field.") 19 | 20 | def __init__(self, *args, **kwargs): 21 | kwargs.setdefault("max_length", VATIN_MAX_LENGTH) 22 | super().__init__(*args, **kwargs) 23 | 24 | def formfield(self, **kwargs): 25 | kwargs.setdefault("form_class", forms.VATINField) 26 | return super().formfield(**kwargs) 27 | -------------------------------------------------------------------------------- /vies/validators.py: -------------------------------------------------------------------------------- 1 | from django.utils.deconstruct import deconstructible 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from vies.types import VATIN 5 | 6 | 7 | @deconstructible 8 | class VATINValidator: 9 | """Validator for European VIES VAT Identification Number.""" 10 | 11 | message = _("Not a valid European VAT number.") 12 | code = "invalid" 13 | 14 | def __init__(self, verify=True, validate=False): 15 | if not (verify or validate): 16 | raise ValueError('"verify" and "validate" can not both be false.') 17 | self.verify = verify 18 | self.validate = validate 19 | 20 | def __call__(self, value): 21 | if isinstance(value, str): 22 | value = VATIN.from_str(value) 23 | if self.verify: 24 | value.verify() 25 | if self.validate: 26 | value.validate() 27 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 4 | DEBUG = True 5 | 6 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 7 | 8 | INSTALLED_APPS = ( 9 | "django.contrib.auth", 10 | "django.contrib.contenttypes", 11 | "django.contrib.sessions", 12 | "django.contrib.staticfiles", 13 | "vies", 14 | "tests.testapp", 15 | ) 16 | 17 | MIDDLEWARE_CLASSES = ( 18 | "django.contrib.sessions.middleware.SessionMiddleware", 19 | "django.contrib.auth.middleware.AuthenticationMiddleware", 20 | "django.contrib.messages.middleware.MessageMiddleware", 21 | ) 22 | 23 | STATIC_URL = "/static/" 24 | 25 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 26 | 27 | SITE_ID = 1 28 | ROOT_URLCONF = "tests.testapp.urls" 29 | 30 | TEMPLATES = [ 31 | {"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True}, 32 | ] 33 | 34 | SECRET_KEY = "123456" 35 | 36 | USE_L10N = True 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | id-token: write 10 | 11 | jobs: 12 | 13 | release-build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v6 18 | - uses: actions/setup-python@v6 19 | with: 20 | python-version: "3.x" 21 | - run: sudo apt update && sudo apt install -y gettext 22 | - run: python -m pip install --upgrade pip build wheel 23 | - run: python -m build --sdist --wheel 24 | - uses: actions/upload-artifact@v6 25 | with: 26 | name: release-dists 27 | path: dist/ 28 | 29 | pypi-publish: 30 | runs-on: ubuntu-latest 31 | needs: 32 | - release-build 33 | permissions: 34 | id-token: write 35 | 36 | steps: 37 | - uses: actions/download-artifact@v7 38 | with: 39 | name: release-dists 40 | path: dist/ 41 | - uses: pypa/gh-action-pypi-publish@release/v1 42 | -------------------------------------------------------------------------------- /vies/locale/da/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: \n" 4 | "Report-Msgid-Bugs-To: \n" 5 | "POT-Creation-Date: 2015-10-28 13:28+0100\n" 6 | "PO-Revision-Date: \n" 7 | "Last-Translator: Christian Pedersen \n" 8 | "Language-Team: \n" 9 | "Language: da\n" 10 | "MIME-Version: 1.0\n" 11 | "Content-Type: text/plain; charset=UTF-8\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 14 | 15 | #: __init__.py:111 16 | #, python-format 17 | msgid "%s is not a valid ISO_3166-1 country code." 18 | msgstr "%s er ikke en gyldig ISO_3166-1 landekode." 19 | 20 | #: __init__.py:114 21 | #, python-format 22 | msgid "%s is not a VIES member country." 23 | msgstr "%s er ikke et VIES medlemsland." 24 | 25 | #: fields.py:20 26 | msgid "Not a valid European VAT number." 27 | msgstr "Ikke et gyldigt EU momsnummer." 28 | 29 | #: fields.py:21 30 | msgid "VIES check VAT service currently unavailable." 31 | msgstr "VIES moms-servicen er ikke tilgængelig i øjeblikket." 32 | 33 | #: models.py:12 34 | msgid "A VIES VAT field." 35 | msgstr "Et VIES momsnummer felt." 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Johannes Hoppe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /vies/forms/fields.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from vies.types import VATIN, VIES_COUNTRY_CHOICES 4 | from vies.validators import VATINValidator 5 | 6 | from .. import VATIN_MAX_LENGTH 7 | from .widgets import VATINHiddenWidget, VATINWidget 8 | 9 | 10 | class VATINField(forms.MultiValueField): 11 | hidden_widget = VATINHiddenWidget 12 | widget = VATINWidget 13 | 14 | def __init__(self, choices=VIES_COUNTRY_CHOICES, *args, **kwargs): 15 | max_length = kwargs.pop("max_length", VATIN_MAX_LENGTH) 16 | 17 | kwargs["widget"] = self.widget(choices=choices) 18 | kwargs.setdefault("validators", [VATINValidator()]) 19 | 20 | fields = ( 21 | forms.ChoiceField(required=False, choices=choices), 22 | forms.CharField(required=False, max_length=max_length), 23 | ) 24 | 25 | # In Django 1.11+, ignore the `empty_value` parameter added by the 26 | # `CharField` superclass at the end of `VATINField.formfield`. 27 | kwargs.pop("empty_value", None) 28 | 29 | super().__init__(fields=fields, *args, **kwargs) 30 | 31 | def compress(self, data_list): 32 | if data_list: 33 | return VATIN(*data_list) 34 | -------------------------------------------------------------------------------- /vies/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: \n" 4 | "Report-Msgid-Bugs-To: \n" 5 | "POT-Creation-Date: 2017-08-29 11:05+0200\n" 6 | "PO-Revision-Date: \n" 7 | "Last-Translator: François-Xavier Thomas \n" 8 | "Language-Team: \n" 9 | "Language: fr\n" 10 | "MIME-Version: 1.0\n" 11 | "Content-Type: text/plain; charset=UTF-8\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 14 | 15 | #: vies/models.py:23 16 | msgid "A VIES VAT field." 17 | msgstr "Champ TVA VIES." 18 | 19 | #: vies/types.py:132 20 | #, python-format 21 | msgid "%s is not a valid ISO_3166-1 country code." 22 | msgstr "%s n'est pas un code pays ISO_3166-1 valide." 23 | 24 | #: vies/types.py:135 25 | #, python-format 26 | msgid "%s is not a european member state." 27 | msgstr "%s n'est pas un état membre de l'Union Européenne." 28 | 29 | #: vies/types.py:144 30 | #, python-format 31 | msgid "%s does not match the country's VAT ID specifications." 32 | msgstr "%s n'est pas un numéro de TVA intracommunautaire valide pour ce pays." 33 | 34 | #: vies/types.py:153 35 | #, python-format 36 | msgid "%s is not a valid VATIN." 37 | msgstr "%s n'est pas un numéro de TVA valide." 38 | 39 | #: vies/validators.py:14 40 | msgid "Not a valid European VAT number." 41 | msgstr "Numéro de TVA Européen invalide." 42 | -------------------------------------------------------------------------------- /vies/locale/pl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: \n" 4 | "Report-Msgid-Bugs-To: \n" 5 | "POT-Creation-Date: 2020-04-10 18:34+0200\n" 6 | "PO-Revision-Date: \n" 7 | "Last-Translator: Jakub Stawowy\n" 8 | "Language-Team: \n" 9 | "Language: pl\n" 10 | "MIME-Version: 1.0\n" 11 | "Content-Type: text/plain; charset=UTF-8\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n" 14 | "%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n" 15 | "%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" 16 | #: vies/models.py:21 17 | msgid "A VIES VAT field." 18 | msgstr "Pole numer VAT." 19 | 20 | #: vies/types.py:129 21 | #, python-format 22 | msgid "%s is not a valid ISO_3166-1 country code." 23 | msgstr "%s nie jest prawidłowym kodem kraju (ISO_3166-1)." 24 | 25 | #: vies/types.py:132 26 | #, python-format 27 | msgid "%s is not a european member state." 28 | msgstr "%s nie jest członkiem Unii Europejskiej." 29 | 30 | #: vies/types.py:141 31 | #, python-format 32 | msgid "%s does not match the country's VAT ID specifications." 33 | msgstr "%s nie jest prawidłowym numer VAT dla wskazanego kraju." 34 | 35 | #: vies/types.py:150 36 | #, python-format 37 | msgid "%s is not a valid VATIN." 38 | msgstr "%s to nieprawidłowy numer VAT." 39 | 40 | #: vies/validators.py:11 41 | msgid "Not a valid European VAT number." 42 | msgstr "Nieprawidłowy europejski numer VAT." 43 | -------------------------------------------------------------------------------- /vies/locale/ES/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: \n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-03-17 17:02+0100\n" 12 | "PO-Revision-Date: \n" 13 | "Last-Translator: Antonio Gutiérrez \n" 14 | "Language-Team: \n" 15 | "Language: es\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: models.py:23 21 | msgid "A VIES VAT field." 22 | msgstr "Campo de validación de IVA." 23 | 24 | #: types.py:132 25 | #, python-format 26 | msgid "%s is not a valid ISO_3166-1 country code." 27 | msgstr "%s no es un código de país válido (ISO_3166-1)." 28 | 29 | #: types.py:135 30 | #, python-format 31 | msgid "%s is not a european member state." 32 | msgstr "%s no es un miembro de la Unión Europea." 33 | 34 | #: types.py:144 35 | #, python-format 36 | msgid "%s does not match the country's VAT ID specifications." 37 | msgstr "%s no coincide con el formato específico del país." 38 | 39 | #: types.py:153 40 | #, python-format 41 | msgid "%s is not a valid VATIN." 42 | msgstr "%s no está registrado en VIES." 43 | 44 | #: validators.py:14 45 | msgid "Not a valid European VAT number." 46 | msgstr "No es un número europeo válido." 47 | -------------------------------------------------------------------------------- /vies/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: \n" 4 | "Report-Msgid-Bugs-To: \n" 5 | "POT-Creation-Date: 2025-11-24 23:39+0100\n" 6 | "PO-Revision-Date: \n" 7 | "Last-Translator: Johannes Hoppe \n" 8 | "Language-Team: \n" 9 | "Language: de\n" 10 | "MIME-Version: 1.0\n" 11 | "Content-Type: text/plain; charset=UTF-8\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "X-Generator: Poedit 3.8\n" 14 | 15 | # Django VIES VAT field. 16 | # Copyright (C) 2015 Johannes Hoppe 17 | # This file is distributed under the same license as the vies package. 18 | # Johannes Hoppe , 2015 19 | # 20 | #: types.py:113 21 | #, python-format 22 | msgid "%s is not a valid ISO_3166-1 country code." 23 | msgstr "%s ist kein gültiger ISO_3166-1 Ländercode." 24 | 25 | #: types.py:116 26 | #, python-format 27 | msgid "%s is not a european member state." 28 | msgstr "%s ist kein europäischer Mitgliedstaat." 29 | 30 | #: types.py:128 31 | #, python-format 32 | msgid "%s does not match the country's VAT ID specifications." 33 | msgstr "%s entspricht nicht den Vorgaben des Landes für die USt-IdNr." 34 | 35 | #: types.py:137 36 | #, python-format 37 | msgid "%s is not a valid VATIN." 38 | msgstr "%s ist keine gültige USt-IdNr." 39 | 40 | #: validators.py:11 41 | msgid "Not a valid European VAT number." 42 | msgstr "Keine gültige europäische Steuernummer." 43 | 44 | #: models.py:18 45 | msgid "A VIES VAT field." 46 | msgstr "Ein USt-IdNr. Feld." 47 | -------------------------------------------------------------------------------- /vies/locale/nl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2021-07-08 16:12+0200\n" 11 | "PO-Revision-Date: \n" 12 | "Last-Translator: Diederik van der Boor \n" 13 | "Language-Team: \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: models.py:21 21 | msgid "A VIES VAT field." 22 | msgstr "Een VIES BTW-nummer veld." 23 | 24 | #: types.py:114 25 | #, python-format 26 | msgid "%s is not a valid ISO_3166-1 country code." 27 | msgstr "%s is geen geldige ISO_3166-1 landcode." 28 | 29 | #: types.py:117 30 | #, python-format 31 | msgid "%s is not a european member state." 32 | msgstr "%s is geen Europese lidstaat." 33 | 34 | #: types.py:129 35 | #, python-format 36 | msgid "%s does not match the country's VAT ID specifications." 37 | msgstr "%s komt niet overeen met de in dit land geldende BTW specificaties." 38 | 39 | #: types.py:138 40 | #, python-format 41 | msgid "%s is not a valid VATIN." 42 | msgstr "%s is geen geldig BTW-nummer." 43 | 44 | #: validators.py:11 45 | msgid "Not a valid European VAT number." 46 | msgstr "Dit is geen geldig Europees BTW-nummer." 47 | 48 | -------------------------------------------------------------------------------- /vies/locale/it/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2017-01-30 19:11+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: models.py:23 22 | msgid "A VIES VAT field." 23 | msgstr "Un campo VIES(VAT Information Exchange System) - IVA." 24 | 25 | #: types.py:132 26 | #, python-format 27 | msgid "%s is not a valid ISO_3166-1 country code." 28 | msgstr "%s non é un codice paese ISO_3166-1 valido." 29 | 30 | #: types.py:135 31 | #, python-format 32 | msgid "%s is not a european member state." 33 | msgstr "%s non e' un paese membro della Comunità Europea." 34 | 35 | #: types.py:144 36 | #, python-format 37 | msgid "%s does not match the country's VAT ID specifications." 38 | msgstr "%s non corrisponde alla partita IVA del paese specificato." 39 | 40 | #: types.py:153 41 | #, python-format 42 | msgid "%s is not a valid VATIN." 43 | msgstr "%s non é una partita IVA valida." 44 | 45 | #: validators.py:14 46 | msgid "Not a valid European VAT number." 47 | msgstr "Non é una partita IVA Europea valida." 48 | -------------------------------------------------------------------------------- /vies/forms/widgets.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import re 3 | 4 | from django import forms 5 | from django.forms.widgets import HiddenInput 6 | 7 | from vies.types import VIES_COUNTRY_CHOICES 8 | 9 | EMPTY_VALUES = (None, "") 10 | 11 | 12 | class VATINWidget(forms.MultiWidget): 13 | def __init__(self, choices=VIES_COUNTRY_CHOICES, attrs=None): 14 | widgets = (forms.Select(choices=choices), forms.TextInput()) 15 | super().__init__(widgets, attrs) 16 | 17 | def value_from_datadict(self, data, files, name): 18 | value = super().value_from_datadict(data, files, name) 19 | country, code = value 20 | if code not in EMPTY_VALUES: 21 | if country in EMPTY_VALUES: 22 | with contextlib.suppress(ValueError): 23 | # ex. code="FR09443710785", country="". 24 | empty, country, code = re.split("([a-zA-Z].)", code) 25 | else: 26 | # ex. code ="FR09443710785", country="FR". 27 | re_code = re.compile(rf"^{country}(\d+)$") 28 | if re_code.match(code): 29 | code = code.replace(country, "", 1) 30 | with contextlib.suppress(AttributeError): 31 | country = country.upper() 32 | return [country, code] 33 | 34 | def format_output(self, rendered_widgets): 35 | return f"{rendered_widgets[0]} {rendered_widgets[1]}" 36 | 37 | def decompress(self, value): 38 | if value: 39 | try: 40 | country, code = value 41 | except ValueError: 42 | country = value[:2] 43 | code = value[2:] 44 | return country, code 45 | return None, None 46 | 47 | 48 | class VATINHiddenWidget(VATINWidget): 49 | """Widget that splits vat input into two inputs.""" 50 | 51 | def __init__(self, attrs=None): 52 | widgets = (HiddenInput(attrs=attrs), HiddenInput(attrs=attrs)) 53 | super(VATINWidget, self).__init__(widgets, attrs) 54 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | 4 | from tests import VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER 5 | from vies.types import VATIN 6 | from vies.validators import VATINValidator 7 | 8 | 9 | class TestValidators: 10 | """Validate values with VATIN object and string values.""" 11 | 12 | @pytest.mark.parametrize( 13 | "vatin", 14 | [ 15 | VATIN(VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER), 16 | "".join([VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER]), 17 | ], 18 | ) 19 | def test_valid(self, vatin): 20 | validator = VATINValidator() 21 | validator(vatin) 22 | 23 | validator = VATINValidator(verify=False, validate=True) 24 | validator(vatin) 25 | 26 | validator = VATINValidator(verify=True, validate=True) 27 | validator(vatin) 28 | 29 | @pytest.mark.parametrize( 30 | "invalid_number_vatin", 31 | [ 32 | VATIN(VALID_VIES_COUNTRY_CODE, "12345678"), 33 | "".join([VALID_VIES_COUNTRY_CODE, "12345678"]), 34 | ], 35 | ) 36 | @pytest.mark.parametrize( 37 | "invalid_country_vatin", 38 | [VATIN("XX", VALID_VIES_NUMBER), "".join(["XX", VALID_VIES_NUMBER])], 39 | ) 40 | def test_invalid(self, invalid_number_vatin, invalid_country_vatin): 41 | validator = VATINValidator() 42 | with pytest.raises(ValidationError): 43 | validator(invalid_country_vatin) 44 | 45 | validator = VATINValidator(verify=False, validate=True) 46 | with pytest.raises(ValidationError): 47 | validator(invalid_number_vatin) 48 | 49 | validator = VATINValidator(verify=True, validate=True) 50 | with pytest.raises(ValidationError): 51 | validator(invalid_country_vatin) 52 | 53 | with pytest.raises(ValidationError): 54 | validator(invalid_number_vatin) 55 | 56 | def test_no_check_exception(self): 57 | with pytest.raises(ValueError): 58 | VATINValidator(verify=False, validate=False) 59 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | 10 | lint: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | lint-command: 15 | - "ruff format --check --diff ." 16 | - "ruff check --output-format=github ." 17 | - "msgcheck -n vies/locale/*/LC_MESSAGES/*.po" 18 | steps: 19 | - uses: actions/checkout@v6 20 | - run: sudo apt install -y gettext aspell libenchant-2-dev 21 | - uses: actions/setup-python@v6 22 | with: 23 | python-version: "3.x" 24 | cache: 'pip' 25 | cache-dependency-path: 'pyproject.toml' 26 | - run: python -m pip install -e .[lint] 27 | - run: ${{ matrix.lint-command }} 28 | 29 | dist: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v6 33 | - run: sudo apt install -y gettext 34 | - uses: actions/setup-python@v6 35 | with: 36 | python-version: "3.x" 37 | - run: python -m pip install --upgrade pip build twine 38 | - run: python -m build --sdist --wheel 39 | - run: python -m twine check dist/* 40 | - uses: actions/upload-artifact@v6 41 | with: 42 | path: dist/* 43 | 44 | pytest: 45 | needs: 46 | - lint 47 | - dist 48 | strategy: 49 | matrix: 50 | os: 51 | - ubuntu-latest 52 | python-version: 53 | - "3.10" 54 | - "3.11" 55 | - "3.12" 56 | - "3.13" 57 | - "3.14" 58 | django-version: 59 | - "4.2" 60 | - "5.1" 61 | - "5.2" 62 | runs-on: ${{ matrix.os }} 63 | steps: 64 | - uses: actions/checkout@v6 65 | - run: sudo apt install -y gettext 66 | - name: Set up Python ${{ matrix.python-version }} 67 | uses: actions/setup-python@v6 68 | with: 69 | python-version: ${{ matrix.python-version }} 70 | - run: python -m pip install .[test] 71 | - run: python -m pip install django~=${{ matrix.django-version }}.0 72 | - run: python -m pytest 73 | - uses: codecov/codecov-action@v5 74 | with: 75 | flags: py${{ matrix.python-version }}-dj${{ matrix.django-version }} 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core>=3.2", "flit-gettext[scm]", "wheel"] 3 | build-backend = "flit_gettext.scm" 4 | 5 | [project] 6 | name = "django-vies" 7 | readme = "README.rst" 8 | license = "MIT" 9 | authors = [{ name = "Johannes Hoppe", email = "info@johanneshoppe.com" }] 10 | requires-python = ">=3.10" 11 | dependencies = [ 12 | "Django>=4.2", 13 | "retrying>=1.1.0", 14 | "zeep>=2.5.0", 15 | ] 16 | dynamic = ["version", "description"] 17 | classifiers = [ 18 | "Development Status :: 5 - Production/Stable", 19 | "Environment :: Web Environment", 20 | "Operating System :: OS Independent", 21 | "Intended Audience :: Developers", 22 | "Intended Audience :: Financial and Insurance Industry", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Programming Language :: Python :: 3.14", 30 | "Framework :: Django", 31 | "Framework :: Django :: 4.2", 32 | "Framework :: Django :: 5.1", 33 | "Framework :: Django :: 5.2", 34 | "Topic :: Software Development", 35 | "Topic :: Office/Business :: Financial :: Accounting", 36 | ] 37 | 38 | [project.optional-dependencies] 39 | test = [ 40 | "pytest", 41 | "pytest-cov", 42 | "pytest-django", 43 | ] 44 | lint = [ 45 | "ruff==0.14.10", 46 | "msgcheck==4.1.0", 47 | ] 48 | 49 | [tool.flit.module] 50 | name = "vies" 51 | 52 | [tool.setuptools_scm] 53 | write_to = "vies/_version.py" 54 | 55 | [tool.pytest.ini_options] 56 | norecursedirs = ["venv", "env", ".eggs"] 57 | addopts = "--cov=vies" 58 | DJANGO_SETTINGS_MODULE = "tests.testapp.settings" 59 | 60 | [tool.coverage.run] 61 | source = ["."] 62 | omit = [ 63 | "*/migrations/*", 64 | "*/tests/*", 65 | "*/test_*.py", 66 | ".tox", 67 | ] 68 | 69 | [tool.coverage.report] 70 | show_missing = true 71 | 72 | [tool.ruff] 73 | line-length = 88 74 | target-version = "py39" 75 | 76 | [tool.ruff.lint] 77 | select = [ 78 | "D", # pydocstyle 79 | "E", # pycodestyle errors 80 | "EXE", # flake8-executable 81 | "F", # pyflakes 82 | "I", # isort 83 | "S", # flake8-bandit 84 | "SIM", # flake8-simplify 85 | "UP", # pyupgrade 86 | "W", # pycodestyle warnings 87 | ] 88 | ignore = ["D1", "PT004"] 89 | 90 | [tool.ruff.lint.per-file-ignores] 91 | "tests/*.py" = ["S101", "S105", "PLR2004"] 92 | 93 | [tool.ruff.lint.isort] 94 | known-first-party = ["measurement", "tests"] 95 | 96 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from types import SimpleNamespace 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | from django.core.exceptions import ValidationError 7 | 8 | from tests import VALID_VIES_COUNTRY_CODE, VALID_VIES_IE, VALID_VIES_NUMBER 9 | from vies.types import VATIN 10 | 11 | 12 | class TestVATIN: 13 | def test_creation(self): 14 | VATIN(VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER) 15 | 16 | def test_str(self): 17 | assert str(VATIN("AB", "1234567890")) == "AB1234567890" 18 | 19 | def test_repr(self): 20 | assert repr(VATIN("AB", "1234567890")) == "" 21 | 22 | def test_verify(self): 23 | with pytest.raises(ValidationError) as e: 24 | VATIN("xx", VALID_VIES_NUMBER).verify() 25 | assert "XX is not a european member state." in e.value 26 | 27 | with pytest.raises(ValidationError) as e: 28 | VATIN("16", VALID_VIES_NUMBER).verify() 29 | assert "16 is not a valid ISO_3166-1 country code." in e.value 30 | 31 | def test_country_code_setter(self): 32 | v = VATIN(VALID_VIES_COUNTRY_CODE.lower(), VALID_VIES_NUMBER) 33 | assert v.country_code == VALID_VIES_COUNTRY_CODE 34 | 35 | def test_is_valid(self): 36 | v = VATIN(VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER) 37 | assert v.is_valid() 38 | 39 | v = VATIN("XX", VALID_VIES_NUMBER) 40 | assert not v.is_valid() 41 | 42 | @patch("vies.types.Client") 43 | def test_result(self, mock_client): 44 | mock_check_vat = mock_client.return_value.service.checkVat 45 | mock_check_vat.return_value = SimpleNamespace( 46 | countryCode="CZ", 47 | vatNumber="24147931", 48 | name="Braiins Systems s.r.o.", 49 | valid=True, 50 | ) 51 | 52 | v = VATIN("CZ", "24147931") 53 | assert v.is_valid() 54 | assert v.data.countryCode == "CZ" 55 | assert v.data.vatNumber == "24147931" 56 | assert v.data.name == "Braiins Systems s.r.o." 57 | 58 | def test_ie_regex_verification(self): 59 | for vn in VALID_VIES_IE: 60 | v = VATIN("IE", vn) 61 | v.verify() 62 | v = VATIN("IE", "1234567890") 63 | with pytest.raises(ValidationError) as e: 64 | v.verify() 65 | assert ( 66 | "IE1234567890 does not match the country's VAT ID specifications." 67 | in e.value 68 | ) 69 | 70 | def test_is_not_valid(self): 71 | """Invalid number.""" 72 | vatin = VATIN("GB", "000000000") 73 | assert not vatin.is_valid() 74 | 75 | @patch("vies.types.Client") 76 | def test_raises_when_zeep_exception(self, mock_client): 77 | """Raise an error if zeep raises an exception.""" 78 | mock_check_vat = mock_client.return_value.service.checkVat 79 | mock_check_vat.side_effect = Exception(500, "error") 80 | 81 | v = VATIN(VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER) 82 | 83 | logging.getLogger("vies").setLevel(logging.CRITICAL) 84 | 85 | with pytest.raises(Exception): 86 | v.validate() 87 | 88 | logging.getLogger("vies").setLevel(logging.NOTSET) 89 | 90 | mock_check_vat.assert_called_with(VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER) 91 | 92 | 93 | @pytest.mark.parametrize( 94 | "number,expected_number", 95 | [ 96 | ("DK99999999", "DK99 99 99 99"), # DK 97 | ("FRXX999999999", "FRXX 999999999"), # FR 98 | ], 99 | ) 100 | def test_formater(number, expected_number): 101 | v = VATIN(number[:2], number[2:]) 102 | assert str(v) == expected_number 103 | -------------------------------------------------------------------------------- /tests/test_widgets.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | from django.contrib.admin.options import ModelAdmin 6 | from django.contrib.admin.sites import AdminSite 7 | from django.test import TestCase 8 | 9 | from tests import VALID_VIES, VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER 10 | from tests.testapp.forms import EmptyVIESModelForm, VIESModelForm 11 | from tests.testapp.models import VIESModel 12 | from vies.forms import VATINWidget 13 | 14 | 15 | class ModelTestCase(TestCase): 16 | def setUp(self): 17 | pass 18 | 19 | def test_create(self): 20 | """Object is correctly created.""" 21 | vies = VIESModel.objects.create(vat=VALID_VIES) 22 | self.assertNotEqual(VIESModel.objects.count(), 0) 23 | self.assertEqual(vies.vat, VALID_VIES) 24 | 25 | def test_save(self): 26 | """Object is correctly saved.""" 27 | vies_saved = VIESModel() 28 | vies_saved.vat = VALID_VIES 29 | vies_saved.save() 30 | 31 | vies_received = VIESModel.objects.get(pk=vies_saved.pk) 32 | self.assertNotEqual(VIESModel.objects.count(), 0) 33 | self.assertEqual(vies_received.vat, VALID_VIES) 34 | 35 | 36 | class ModelFormTestCase(TestCase): 37 | def test_is_valid(self): 38 | """Form is valid.""" 39 | form = VIESModelForm( 40 | {"vat_0": VALID_VIES_COUNTRY_CODE, "vat_1": VALID_VIES_NUMBER} 41 | ) 42 | self.assertTrue(form.is_valid()) 43 | 44 | vies = form.save() 45 | self.assertEqual(vies.vat, VALID_VIES) 46 | 47 | def test_is_not_valid_country(self): 48 | """Invalid country.""" 49 | form = VIESModelForm({"vat_0": "xx", "vat_1": VALID_VIES_NUMBER}) 50 | self.assertFalse(form.is_valid()) 51 | 52 | def test_is_not_valid_numbers(self): 53 | """Invalid number.""" 54 | form = VIESModelForm({"vat_0": VALID_VIES_COUNTRY_CODE, "vat_1": "xx123+-"}) 55 | self.assertFalse(form.is_valid()) 56 | 57 | def test_save(self): 58 | """Form is saved.""" 59 | form = VIESModelForm( 60 | {"vat_0": VALID_VIES_COUNTRY_CODE, "vat_1": VALID_VIES_NUMBER} 61 | ) 62 | self.assertTrue(form.is_valid()) 63 | vies_saved = form.save() 64 | 65 | vies_received = VIESModel.objects.get(pk=vies_saved.pk) 66 | self.assertEqual(vies_received, vies_saved) 67 | self.assertNotEqual(VIESModel.objects.count(), 0) 68 | self.assertEqual(vies_received.vat, VALID_VIES) 69 | 70 | def test_empty(self): 71 | form = EmptyVIESModelForm({"name": "Eva"}) 72 | self.assertTrue(form.is_valid()) 73 | 74 | @patch("vies.types.Client") 75 | def test_is_valid_and_has_vatin_data(self, mock_client): 76 | """Valid VATINFields' vatin_data() return result dict.""" 77 | mock_client.return_value.service.checkVat.return_value = SimpleNamespace( 78 | countryCode="CZ", 79 | vatNumber="24147931", 80 | name="Braiins Systems s.r.o.", 81 | valid=True, 82 | ) 83 | 84 | form = VIESModelForm({"vat_0": "CZ", "vat_1": "24147931"}) 85 | 86 | assert form.is_valid() 87 | data = form.cleaned_data["vat"].data 88 | assert data.name == "Braiins Systems s.r.o." 89 | 90 | 91 | class TestWidget: 92 | @pytest.mark.parametrize( 93 | "given_value", 94 | [ 95 | [VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER], 96 | ["", f"{VALID_VIES_COUNTRY_CODE}{VALID_VIES_NUMBER}"], 97 | [ 98 | VALID_VIES_COUNTRY_CODE, 99 | f"{VALID_VIES_COUNTRY_CODE}{VALID_VIES_NUMBER}", 100 | ], 101 | ], 102 | ) 103 | def test_value_from_datadict(self, given_value): 104 | widget = VATINWidget() 105 | data = {f"my_field_{i:d}": value for i, value in enumerate(given_value)} 106 | v = widget.value_from_datadict(data, [], "my_field") 107 | assert v == [VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER] 108 | 109 | def test_decompress(self): 110 | assert VATINWidget().decompress( 111 | f"{VALID_VIES_COUNTRY_CODE}{VALID_VIES_NUMBER}" 112 | ) == (VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER) 113 | assert VATINWidget().decompress( 114 | [VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER] 115 | ) == (VALID_VIES_COUNTRY_CODE, VALID_VIES_NUMBER) 116 | assert VATINWidget().decompress(None) == (None, None) 117 | 118 | 119 | class MockRequest: 120 | pass 121 | 122 | 123 | request = MockRequest() 124 | 125 | 126 | class AdminTestCase(TestCase): 127 | def setUp(self): 128 | self.site = AdminSite() 129 | 130 | def test_vatin_field_admin(self): 131 | """Admin form is generated.""" 132 | ma = ModelAdmin(VIESModel, self.site) 133 | 134 | try: 135 | ma.get_form(request) 136 | except Exception as e: 137 | self.fail(e.message) 138 | -------------------------------------------------------------------------------- /vies/types.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.utils.functional import cached_property 5 | from django.utils.translation import gettext 6 | from zeep import Client 7 | 8 | from vies import VIES_WSDL_URL, logger 9 | 10 | 11 | def dk_format(v): 12 | return f"{v[:4]} {v[4:6]} {v[6:8]} {v[8:10]}" 13 | 14 | 15 | def fr_format(v): 16 | return f"{v[:4]} {v[4:]}" 17 | 18 | 19 | VIES_OPTIONS = { 20 | "AT": ("Austria", re.compile(r"^ATU\d{8}$")), 21 | "BE": ("Belgium", re.compile(r"^BE(0|1)\d{9}$")), 22 | "BG": ("Bulgaria", re.compile(r"^BG\d{9,10}$")), 23 | "HR": ("Croatia", re.compile(r"^HR\d{11}$")), 24 | "CHE": ("Switzerland", re.compile(r"^CHE\d{9}$")), 25 | "CY": ("Cyprus", re.compile(r"^CY\d{8}[A-Z]$")), 26 | "CZ": ("Czech Republic", re.compile(r"^CZ\d{8,10}$")), 27 | "DE": ("Germany", re.compile(r"^DE\d{9}$")), 28 | "DK": ("Denmark", re.compile(r"^DK\d{8}$"), dk_format), 29 | "EE": ("Estonia", re.compile(r"^EE\d{9}$")), 30 | "EL": ("Greece", re.compile(r"^EL\d{9}$")), 31 | "ES": ("Spain", re.compile(r"^ES[A-Z0-9]\d{7}[A-Z0-9]$")), 32 | "FI": ("Finland", re.compile(r"^FI\d{8}$")), 33 | "FR": ("France", re.compile(r"^FR[A-HJ-NP-Z0-9][A-HJ-NP-Z0-9]\d{9}$"), fr_format), 34 | "HU": ("Hungary", re.compile(r"^HU\d{8}$")), 35 | "IE": ("Ireland", re.compile(r"^IE\d[A-Z0-9\+\*]\d{5}[A-Z]{1,2}$")), 36 | "IT": ("Italy", re.compile(r"^IT\d{11}$")), 37 | "LT": ("Lithuania", re.compile(r"^LT(\d{9}|\d{12})$")), 38 | "LU": ("Luxembourg", re.compile(r"^LU\d{8}$")), 39 | "LV": ("Latvia", re.compile(r"^LV\d{11}$")), 40 | "MT": ("Malta", re.compile(r"^MT\d{8}$")), 41 | "NL": ("The Netherlands", re.compile(r"^NL\d{9}B\d{2}$")), 42 | "PL": ("Poland", re.compile(r"^PL\d{10}$")), 43 | "PT": ("Portugal", re.compile(r"^PT\d{9}$")), 44 | "RO": ("Romania", re.compile(r"^RO\d{2,10}$")), 45 | "SE": ("Sweden", re.compile(r"^SE\d{10}01$")), 46 | "SI": ("Slovenia", re.compile(r"^SI\d{8}$")), 47 | "SK": ("Slovakia", re.compile(r"^SK\d{10}$")), 48 | "XI": ("Northern Ireland", re.compile(r"^XI\d{9}$")), 49 | } 50 | 51 | VIES_COUNTRY_CHOICES = sorted( 52 | (("", "--"),) + tuple((key, key) for key, value in VIES_OPTIONS.items()) 53 | ) 54 | 55 | MEMBER_COUNTRY_CODES = VIES_OPTIONS.keys() 56 | 57 | 58 | class VATIN: 59 | """Object wrapper for the european VAT Identification Number.""" 60 | 61 | def __init__(self, country_code, number): 62 | self.country_code = country_code 63 | self.number = number 64 | 65 | def __str__(self): 66 | unformated_number = f"{self.country_code}{self.number}" 67 | 68 | country = VIES_OPTIONS.get(self.country_code, {}) 69 | if len(country) == 3: 70 | return country[2](unformated_number) 71 | return unformated_number 72 | 73 | def __repr__(self): 74 | return f"" 75 | 76 | def get_country_code(self): 77 | return self._country_code 78 | 79 | def set_country_code(self, value): 80 | self._country_code = value.upper() 81 | 82 | country_code = property(get_country_code, set_country_code) 83 | 84 | def get_number(self): 85 | return self._number 86 | 87 | def set_number(self, value): 88 | self._number = value.upper().replace(" ", "") 89 | 90 | number = property(get_number, set_number) 91 | 92 | @cached_property 93 | def data(self): 94 | """VIES API response data.""" 95 | client = Client(VIES_WSDL_URL) 96 | try: 97 | return client.service.checkVat(self.country_code, self.number) 98 | except Exception as e: 99 | logger.exception(e) 100 | raise 101 | 102 | def is_valid(self): 103 | try: 104 | self.verify() 105 | self.validate() 106 | except ValidationError: 107 | return False 108 | else: 109 | return True 110 | 111 | def verify_country_code(self): 112 | if not re.match(r"^[a-zA-Z]", self.country_code): 113 | msg = gettext("%s is not a valid ISO_3166-1 country code.") 114 | raise ValidationError(msg % self.country_code) 115 | if self.country_code not in MEMBER_COUNTRY_CODES: 116 | msg = gettext("%s is not a european member state.") 117 | raise ValidationError(msg % self.country_code) 118 | 119 | def verify_regex(self): 120 | country = dict( 121 | map( 122 | lambda x, y: (x, y), 123 | ("country", "validator", "formatter"), 124 | VIES_OPTIONS[self.country_code], 125 | ) 126 | ) 127 | if not country["validator"].match(f"{self.country_code}{self.number}"): 128 | msg = gettext("%s does not match the country's VAT ID specifications.") 129 | raise ValidationError(msg % self) 130 | 131 | def verify(self): 132 | self.verify_country_code() 133 | self.verify_regex() 134 | 135 | def validate(self): 136 | if not self.data.valid: 137 | msg = gettext("%s is not a valid VATIN.") 138 | raise ValidationError(msg % self) 139 | 140 | @classmethod 141 | def from_str(cls, value): 142 | """Return a VATIN object by given string.""" 143 | return cls(value[:2].strip(), value[2:].strip()) 144 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Django-VIES 3 | =========== 4 | 5 | Validate and store VAT Information Exchange System (VIES) data in Django. 6 | 7 | Installation 8 | ------------ 9 | 10 | .. code:: shell 11 | 12 | python3 -m pip install django-vies 13 | 14 | Usage 15 | ----- 16 | 17 | ``VATINField`` for models 18 | 19 | .. code:: python 20 | 21 | from django.db import models 22 | from vies.models import VATINField 23 | 24 | class Company(models.Model): 25 | name = models.CharField(max_length=100) 26 | vat = VATINField(blank=True, null=True) 27 | 28 | ``VATIN`` wrapper class, allows access to result. 29 | 30 | .. code:: python 31 | 32 | >>> from vies.types import VATIN 33 | >>> vat = VATIN('LU', '26375245') 34 | >>> vat.is_valid() 35 | True 36 | >>> vat.data 37 | { 38 | 'countryCode': 'LU', 39 | 'vatNumber': '26375245', 40 | 'requestDate': datetime.date(2020, 4, 13), 41 | 'valid': True, 42 | 'name': 'AMAZON EUROPE CORE S.A R.L.', 43 | 'address': '38, AVENUE JOHN F. KENNEDY\nL-1855 LUXEMBOURG' 44 | } 45 | 46 | 47 | You can also use the classmethod ``VATIN.from_str`` to create ``VATIN`` 48 | from ``str``. 49 | 50 | .. code:: python 51 | 52 | >>> from vies.types import VATIN 53 | >>> vat = VATIN.from_str('LU26375245') 54 | >>> vat.is_valid() 55 | True 56 | 57 | The VIES API endpoint can be very unreliable and seems to have an IP based access limit. 58 | Therefore the ``VATINField`` does NOT perform API based validation by default. It needs 59 | to be explicitly turned on or performed in a separate task. 60 | 61 | e.g. 62 | 63 | .. code:: python 64 | 65 | from vies.models import VATINField 66 | from vies.validators import VATINValidator 67 | 68 | 69 | class Company(models.Model): 70 | name = models.CharField(max_length=100) 71 | vat = VATINField(validators=[VATINValidator(verify=True, validate=True)]) 72 | 73 | ``validate=True`` will tell the validator to validate against the VIES API. 74 | ``verify`` is enabled on by default and will only verify that the VATIN matches the country's specifications. 75 | 76 | It is recommended to perform VIES API validation inside an asynchronous task. 77 | 78 | e.g. using celery 79 | 80 | .. code:: python 81 | 82 | from celery import shared_task 83 | from vies.models import VATINField 84 | from vies.types import VATIN 85 | from django.core.exceptions import ValidationError 86 | 87 | 88 | class Company(models.Model): 89 | name = models.CharField(max_length=100) 90 | vat = VATINField() 91 | vat_is_valid = models.BooleanField(default=False) 92 | 93 | def __init__(self, *args, **kwargs): 94 | super(Company, self).__init__(*args, **kwargs) 95 | self.__vat = self.vat 96 | 97 | def save(self, *args, **kwargs): 98 | if self.__vat != self.vat: 99 | validate_vat_field.delay(self.pk) 100 | super(Company, self).save(*args, **kwargs) 101 | self.__vat = self.vat 102 | 103 | def refresh_from_db(self, *args, **kwargs) 104 | super(Company, self).refresh_from_db(*args, **kwargs) 105 | self.__vat = self.vat 106 | 107 | 108 | @shared_task 109 | def validate_vat_field(company_id): 110 | company = Company.objects.get(pk=company_id) 111 | vat = VATIN.from_str(company.vat) 112 | try: 113 | vat.validate() 114 | except ValidationError: 115 | company.vat_is_valid = False 116 | else: 117 | company.vat_is_valid = True 118 | finally: 119 | company.save(update_fields=['vat_is_valid']) 120 | 121 | You can also use ``celery.current_app.send_task('validate_vat_field', kwargs={"company_id": self.pk})`` to call asynchronous task to avoid **circular import errors**. 122 | 123 | Translations 124 | ------------ 125 | 126 | Feel free to contribute translations, it's simple! 127 | 128 | .. code:: shell 129 | 130 | cd vies 131 | django-admin makemessages -l $YOUR_COUNTRY_CODE 132 | 133 | Just edit the generated PO file. Pull-Requests are welcome! 134 | 135 | 136 | License 137 | ------- 138 | The MIT License (MIT) 139 | 140 | Copyright (c) 2014-2016 Johannes Hoppe 141 | 142 | Permission is hereby granted, free of charge, to any person obtaining a copy of 143 | this software and associated documentation files (the "Software"), to deal in 144 | the Software without restriction, including without limitation the rights to 145 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 146 | the Software, and to permit persons to whom the Software is furnished to do so, 147 | subject to the following conditions: 148 | 149 | The above copyright notice and this permission notice shall be included in all 150 | copies or substantial portions of the Software. 151 | 152 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 153 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 154 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 155 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 156 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 157 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 158 | --------------------------------------------------------------------------------