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/2] 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/2] 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