From 498c9c7775c41919220dfaab6da9b67a48886a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 8 Dec 2023 16:04:51 +0100 Subject: [PATCH 1/5] Add European Excise Number --- stdnum/eu/excise.py | 84 ++++++++++++++++++++++++++++++++++++ tests/test_eu_excise.doctest | 64 +++++++++++++++++++++++++++ tests/test_eu_excise.py | 45 +++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 stdnum/eu/excise.py create mode 100644 tests/test_eu_excise.doctest create mode 100644 tests/test_eu_excise.py diff --git a/stdnum/eu/excise.py b/stdnum/eu/excise.py new file mode 100644 index 00000000..25625d34 --- /dev/null +++ b/stdnum/eu/excise.py @@ -0,0 +1,84 @@ +# 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. + +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 stdnum.eu.vat import MEMBER_STATES +from stdnum.exceptions import * +from stdnum.util import clean, get_soap_client + + +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 compact(number): + """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): + """Check if the number is a valid Excise number.""" + number = clean(number, ' ').upper().strip() + cc = number[:2] + if cc.lower() not in MEMBER_STATES: + raise InvalidComponent() + if len(number) != 13: + raise InvalidLength() + return number + + +def is_valid(number): + """Check if the number is a valid Excise number.""" + try: + return bool(validate(number)) + except ValidationError: + return False + + +def check_seed(number, timeout=30): # 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) 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..dd36044a --- /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): + """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') From 4d832c2451b2335badf7a28b529224ad173bd297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Fri, 8 Dec 2023 18:48:54 +0100 Subject: [PATCH 2/5] Add accise - the french number for excise --- stdnum/eu/excise.py | 19 +++++++-- stdnum/fr/__init__.py | 3 +- stdnum/fr/accise.py | 95 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 stdnum/fr/accise.py diff --git a/stdnum/eu/excise.py b/stdnum/eu/excise.py index 25625d34..db64c9e0 100644 --- a/stdnum/eu/excise.py +++ b/stdnum/eu/excise.py @@ -41,13 +41,25 @@ from stdnum.eu.vat import MEMBER_STATES from stdnum.exceptions import * -from stdnum.util import clean, get_soap_client +from stdnum.util import clean, get_cc_module, get_soap_client +_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): + """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): """Convert the number to the minimal representation. This strips the number of any valid separators and removes surrounding whitespace.""" @@ -59,10 +71,11 @@ def validate(number): """Check if the number is a valid Excise number.""" number = clean(number, ' ').upper().strip() cc = number[:2] - if cc.lower() not in MEMBER_STATES: - raise InvalidComponent() if len(number) != 13: raise InvalidLength() + module = _get_cc_module(cc) + if module: + module.validate(number) return number diff --git a/stdnum/fr/__init__.py b/stdnum/fr/__init__.py index 14abaa51..3d8d3a79 100644 --- a/stdnum/fr/__init__.py +++ b/stdnum/fr/__init__.py @@ -20,5 +20,6 @@ """Collection of French numbers.""" -# 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..8a18313d --- /dev/null +++ b/stdnum/fr/accise.py @@ -0,0 +1,95 @@ +# 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. + +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 stdnum.exceptions import * +from stdnum.util import clean, isdigits + + +OPERATORS = set(['E', 'N', 'C', 'B']) + + +def compact(number): + """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): + """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): + """Check if the number is a valid accise number.""" + try: + return bool(validate(number)) + except ValidationError: + return False From 73abd01e3402837d73367352761638d5216a9914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 28 Sep 2025 12:45:19 +0200 Subject: [PATCH 3/5] Improve docstrings --- stdnum/eu/excise.py | 3 ++- stdnum/fr/accise.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/stdnum/eu/excise.py b/stdnum/eu/excise.py index db64c9e0..5825b24f 100644 --- a/stdnum/eu/excise.py +++ b/stdnum/eu/excise.py @@ -21,7 +21,8 @@ """Excise Number The Excise Number is the identification number issued by the competent -authority in respect of the person or premises. +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); diff --git a/stdnum/fr/accise.py b/stdnum/fr/accise.py index 8a18313d..3c4728d3 100644 --- a/stdnum/fr/accise.py +++ b/stdnum/fr/accise.py @@ -18,7 +18,7 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301 USA -"""n° d'accise. +"""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 From da9a8bd3b33625518cf93bcb14ef0e4f30ea2a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 28 Sep 2025 13:28:32 +0200 Subject: [PATCH 4/5] Add documentation --- docs/index.rst | 2 ++ docs/stdnum.eu.excise.rst | 5 +++++ docs/stdnum.fr.accise.rst | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 docs/stdnum.eu.excise.rst create mode 100644 docs/stdnum.fr.accise.rst 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: From 468f84def51f51030bcdc4e67688ea9ce893cf26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 28 Sep 2025 13:42:52 +0200 Subject: [PATCH 5/5] Add typing --- stdnum/eu/excise.py | 25 ++++++++++++++++++------- stdnum/fr/accise.py | 8 +++++--- tests/test_eu_excise.py | 2 +- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/stdnum/eu/excise.py b/stdnum/eu/excise.py index 5825b24f..75e363fb 100644 --- a/stdnum/eu/excise.py +++ b/stdnum/eu/excise.py @@ -40,9 +40,17 @@ 'LU00000987ABC' """ +from __future__ import annotations + from stdnum.eu.vat import MEMBER_STATES from stdnum.exceptions import * -from stdnum.util import clean, get_cc_module, get_soap_client +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() @@ -51,7 +59,7 @@ """The WSDL URL of the System for Exchange of Excise Data (SEED).""" -def _get_cc_module(cc): +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: @@ -61,14 +69,14 @@ def _get_cc_module(cc): return _country_modules[cc] -def compact(number): +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): +def validate(number: str) -> str: """Check if the number is a valid Excise number.""" number = clean(number, ' ').upper().strip() cc = number[:2] @@ -80,7 +88,7 @@ def validate(number): return number -def is_valid(number): +def is_valid(number: str) -> bool: """Check if the number is a valid Excise number.""" try: return bool(validate(number)) @@ -88,11 +96,14 @@ def is_valid(number): return False -def check_seed(number, timeout=30): # pragma: no cover (not part of normal test suite) +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) + return client.verifyExcise(number) # type: ignore[no-any-return] diff --git a/stdnum/fr/accise.py b/stdnum/fr/accise.py index 3c4728d3..2fffcb18 100644 --- a/stdnum/fr/accise.py +++ b/stdnum/fr/accise.py @@ -53,6 +53,8 @@ InvalidFormat: ... """ +from __future__ import annotations + from stdnum.exceptions import * from stdnum.util import clean, isdigits @@ -60,14 +62,14 @@ OPERATORS = set(['E', 'N', 'C', 'B']) -def compact(number): +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): +def validate(number: str) -> str: """Check if the number is a valid accise number. This checks the length, formatting.""" number = clean(number, ' ').upper().strip() @@ -87,7 +89,7 @@ def validate(number): return number -def is_valid(number): +def is_valid(number: str) -> bool: """Check if the number is a valid accise number.""" try: return bool(validate(number)) diff --git a/tests/test_eu_excise.py b/tests/test_eu_excise.py index dd36044a..1dc1bfbc 100644 --- a/tests/test_eu_excise.py +++ b/tests/test_eu_excise.py @@ -36,7 +36,7 @@ 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): + def test_check_seed(self) -> None: """Test stdnum.eu.excise.check_seed()""" result = excise.check_seed('FR012907E0820') self.assertTrue('errorDescription' not in result)