diff --git a/docs/index.rst b/docs/index.rst index a543b398..5d8314af 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -194,6 +194,7 @@ Available formats eu.banknote eu.ecnumber eu.eic + eu.excise eu.nace eu.oss eu.vat @@ -204,6 +205,7 @@ Available formats fi.ytunnus figi fo.vn + fr.accise fr.nif fr.nir fr.siren diff --git a/docs/stdnum.eu.excise.rst b/docs/stdnum.eu.excise.rst new file mode 100644 index 00000000..cac6e7d4 --- /dev/null +++ b/docs/stdnum.eu.excise.rst @@ -0,0 +1,5 @@ +stdnum.eu.excise +================ + +.. automodule:: stdnum.eu.excise + :members: diff --git a/docs/stdnum.fr.accise.rst b/docs/stdnum.fr.accise.rst new file mode 100644 index 00000000..d06dc7cc --- /dev/null +++ b/docs/stdnum.fr.accise.rst @@ -0,0 +1,5 @@ +stdnum.fr.accise +================ + +.. automodule:: stdnum.fr.accise + :members: diff --git a/stdnum/eu/excise.py b/stdnum/eu/excise.py new file mode 100644 index 00000000..75e363fb --- /dev/null +++ b/stdnum/eu/excise.py @@ -0,0 +1,109 @@ +# excise.py - functions for handling EU Excise numbers +# coding: utf-8 +# +# Copyright (C) 2023 Cédric Krier +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301 USA + +"""Excise Number + +The Excise Number is the identification number issued by the competent +authority in respect of the person or premises. It is used to identify the +taxpayers of excise taxes. + +The first two letters are the ISO country code of the Member State where the +operator is located (e.g. LU); +The next 11 alphanumeric characters are the identifier of the operator. +The identifier must include 11 digits, shorter identifiers must be padded to +the left with zeroes (e.g. 00000987ABC) + +More information: + +* https://ec.europa.eu/taxation_customs/dds2/seed/help/seedhedn.jsp + +>>> compact('LU 00000987ABC') +'LU00000987ABC' +>>> validate('LU00000987ABC') +'LU00000987ABC' +""" + +from __future__ import annotations + +from stdnum.eu.vat import MEMBER_STATES +from stdnum.exceptions import * +from stdnum.util import ( + NumberValidationModule, clean, get_cc_module, get_soap_client) + + +TYPE_CHECKING = False +if TYPE_CHECKING: # pragma: no cover (typechecking only import) + from typing import Any + + +_country_modules = dict() + +seed_wsdl = 'https://ec.europa.eu/taxation_customs/dds2/seed/services/excise/verification?wsdl' +"""The WSDL URL of the System for Exchange of Excise Data (SEED).""" + + +def _get_cc_module(cc: str) -> NumberValidationModule | None: + """Get the Excise number module based on the country code.""" + cc = cc.lower() + if cc not in MEMBER_STATES: + raise InvalidComponent() + if cc not in _country_modules: + _country_modules[cc] = get_cc_module(cc, 'excise') + return _country_modules[cc] + + +def compact(number: str) -> str: + """Convert the number to the minimal representation. This strips the number + of any valid separators and removes surrounding whitespace.""" + number = clean(number, ' ').upper().strip() + return number + + +def validate(number: str) -> str: + """Check if the number is a valid Excise number.""" + number = clean(number, ' ').upper().strip() + cc = number[:2] + if len(number) != 13: + raise InvalidLength() + module = _get_cc_module(cc) + if module: + module.validate(number) + return number + + +def is_valid(number: str) -> bool: + """Check if the number is a valid Excise number.""" + try: + return bool(validate(number)) + except ValidationError: + return False + + +def check_seed( + number: str, + timeout: float = 30, +) -> dict[str, Any]: # pragma: no cover (not part of normal test suite) + """Query the online European Commission System for Exchange of Excise Data + (SEED) for validity of the provided number. Note that the service has + usage limitations (see the VIES website for details). The timeout is in + seconds. This returns a dict-like object.""" + number = compact(number) + client = get_soap_client(seed_wsdl, timeout) + return client.verifyExcise(number) # type: ignore[no-any-return] diff --git a/stdnum/fr/__init__.py b/stdnum/fr/__init__.py index abb5e236..70293e54 100644 --- a/stdnum/fr/__init__.py +++ b/stdnum/fr/__init__.py @@ -22,5 +22,6 @@ from __future__ import annotations -# provide vat as an alias +# provide excise and vat as an alias +from stdnum.fr import accise as excise # noqa: F401 from stdnum.fr import tva as vat # noqa: F401 diff --git a/stdnum/fr/accise.py b/stdnum/fr/accise.py new file mode 100644 index 00000000..2fffcb18 --- /dev/null +++ b/stdnum/fr/accise.py @@ -0,0 +1,97 @@ +# accise.py - functions for handling French Accise numbers +# coding: utf-8 +# +# Copyright (C) 2023 Cédric Krier +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301 USA + +"""n° d'accise (French number to identify taxpayers of excise taxes). + +The n° d'accise always start by FR0 following by the 2 ending digits of the +year, 3 number of customs office, one letter for the type and an ordering +number of 4 digits. + +>>> compact('FR0 23 004 N 9448') +'FR023004N9448' +>>> validate('FR023004N9448') +'FR023004N9448' +>>> validate('FR012907E0820') +'FR012907E0820' + +>>> validate('FR012345') +Traceback (most recent call last): + ... +InvalidLength: ... +>>> validate('FR0XX907E0820') +Traceback (most recent call last): + ... +InvalidFormat: ... +>>> validate('FR012XXXE0820') +Traceback (most recent call last): + ... +InvalidFormat: ... +>>> validate('FR012907A0820') +Traceback (most recent call last): + ... +InvalidFormat: ... +>>> validate('FR012907EXXXX') +Traceback (most recent call last): + ... +InvalidFormat: ... +""" + +from __future__ import annotations + +from stdnum.exceptions import * +from stdnum.util import clean, isdigits + + +OPERATORS = set(['E', 'N', 'C', 'B']) + + +def compact(number: str) -> str: + """Convert the number to the minimal representation. This strips the number + of any valid separators and removes surrounding whitespace.""" + number = clean(number, ' ').upper().strip() + return number + + +def validate(number: str) -> str: + """Check if the number is a valid accise number. This checks the length, + formatting.""" + number = clean(number, ' ').upper().strip() + code = number[:3] + if code != 'FR0': + raise InvalidFormat() + if len(number) != 13: + raise InvalidLength() + if not isdigits(number[3:5]): + raise InvalidFormat() + if not isdigits(number[5:8]): + raise InvalidFormat() + if number[8] not in OPERATORS: + raise InvalidFormat() + if not isdigits(number[9:12]): + raise InvalidFormat() + return number + + +def is_valid(number: str) -> bool: + """Check if the number is a valid accise number.""" + try: + return bool(validate(number)) + except ValidationError: + return False diff --git a/tests/test_eu_excise.doctest b/tests/test_eu_excise.doctest new file mode 100644 index 00000000..6c5c39a2 --- /dev/null +++ b/tests/test_eu_excise.doctest @@ -0,0 +1,64 @@ +test_eu_excise.doctest - more detailed doctests for the stdnum.eu.excise module + +Copyright (C) 2023 Cédric Krier + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +02110-1301 USA + +This file contains more detailed doctests for the stdnum.eu.excise module. It +tries to validate a number of Excise numbers that have been found online. + +>>> from stdnum.eu import excise +>>> from stdnum.exceptions import * + +These have been found online and should all be valid numbers. + +>>> numbers = ''' +... +... LU 00000987ABC +... FR012907E0820 +... ''' +>>> [x for x in numbers.splitlines() if x and not excise.is_valid(x)] +[] + +The following numbers are wrong in one way or another. First we need a +function to be able to determine the kind of error. + +>>> def caught(number, exception): +... try: +... excise.validate(number) +... return False +... except exception: +... return True +... + + +These numbers should be mostly valid except that they have the wrong length. + +>>> numbers = ''' +... +... LU987ABC +... ''' +>>> [x for x in numbers.splitlines() if x and not caught(x, InvalidLength)] +[] + +These numbers should be mostly valid except that they have the wrong prefix + +>>> numbers = ''' +... +... XX00000987ABC +... ''' +>>> [x for x in numbers.splitlines() if x and not caught(x, InvalidComponent)] +[] diff --git a/tests/test_eu_excise.py b/tests/test_eu_excise.py new file mode 100644 index 00000000..1dc1bfbc --- /dev/null +++ b/tests/test_eu_excise.py @@ -0,0 +1,45 @@ +# test_eu_excise.py - functions for testing the online SEED validation +# coding: utf-8 +# +# Copyright (C) 2023 Cédric Krier +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301 USA + +# This is a separate test file because it should not be run regularly +# because it could negatively impact the SEED service. + +"""Extra tests for the stdnum.eu.excise module.""" + +import os +import unittest + +from stdnum.eu import excise + + +@unittest.skipIf( + not os.environ.get('ONLINE_TESTS'), + 'Do not overload online services') +class TestSeed(unittest.TestCase): + """Test the SEED web service provided by the European commission for + validation Excise numbers of European countries.""" + + def test_check_seed(self) -> None: + """Test stdnum.eu.excise.check_seed()""" + result = excise.check_seed('FR012907E0820') + self.assertTrue('errorDescription' not in result) + self.assertTrue(len(result['result']) > 0) + first = result['result'][0] + self.assertEqual(first['excise'], 'FR012907E0820')