Repository: identique/idnumbers (language: python)

Issue:
id number for Luxembourg
https://en.wikipedia.org/wiki/National_identification_number#Luxembourg


Discussion / hints:

Fix patch:
diff --git a/idnumbers/nationalid/LUX.py b/idnumbers/nationalid/LUX.py
new file mode 100644
index 00000000..0c77e6e7
--- /dev/null
+++ b/idnumbers/nationalid/LUX.py
@@ -0,0 +1,81 @@
+import re
+from datetime import date
+from types import SimpleNamespace
+from typing import Optional, TypedDict
+from .util import CHECK_DIGIT, validate_regexp, luhn_digit, verhoeff_check
+
+
+class ParseResult(TypedDict):
+    """The parse result of Luxembourg ID"""
+    yyyymmdd: date
+    """dob"""
+    sn: str
+    """serial number"""
+    checksum1: CHECK_DIGIT
+    """check digit"""
+    checksum2: CHECK_DIGIT
+    """check digit"""
+
+
+class NationalID:
+    """
+    Luxembourg National ID number format
+    https://en.wikipedia.org/wiki/National_identification_number#Luxembourg
+    https://www.oecd.org/tax/automatic-exchange/crs-implementation-and-assistance/tax-identification-numbers/Luxembourg-TIN.pdf
+    """
+    METADATA = SimpleNamespace(**{
+        'iso3166_alpha2': 'LU',
+        'min_length': 13,
+        'max_length': 13,
+        'parsable': True,
+        'checksum': True,
+        'regexp': re.compile(r'^(?P<yyyy>\d{4})(?P<mm>\d{2})(?P<dd>\d{2})'
+                             r'(?P<sn>\d{3})'
+                             r'(?P<checksum1>\d)'
+                             r'(?P<checksum2>\d)$')
+    })
+
+    @staticmethod
+    def validate(id_number: str) -> bool:
+        """validate"""
+        if not validate_regexp(id_number, NationalID.METADATA.regexp):
+            return False
+        return NationalID.parse(id_number) is not None
+
+    @staticmethod
+    def parse(id_number: str) -> Optional[ParseResult]:
+        """parse the result"""
+        match_obj = NationalID.METADATA.regexp.match(id_number)
+        if not match_obj:
+            return None
+        checksum = NationalID.checksum(id_number)
+        if not checksum:
+            return None
+        yyyy = int(match_obj.group('yyyy'))
+        mm = int(match_obj.group('mm'))
+        dd = int(match_obj.group('dd'))
+        try:
+            return {
+                'yyyymmdd': date(yyyy, mm, dd),
+                'sn': match_obj.group('sn'),
+                'checksum1': int(match_obj.group('checksum1')),
+                'checksum2': int(match_obj.group('checksum2'))
+            }
+        except ValueError:
+            # catch the date error
+            return None
+
+    @staticmethod
+    def checksum(id_number) -> bool:
+        """check the checksum"""
+        if not validate_regexp(id_number, NationalID.METADATA.regexp):
+            return False
+        numbers = [int(char) for char in id_number]
+        check1 = luhn_digit(numbers[0:-2], True)
+        if check1 != numbers[-2]:
+            return False
+        # clone the numbers and remove the 12th digit
+        check2_numbers = list(numbers)
+        del check2_numbers[-2]
+        # perform verhoeff check
+        return verhoeff_check(check2_numbers)


Test patch:
diff --git a/tests/nationalid/test_LUX.py b/tests/nationalid/test_LUX.py
new file mode 100644
index 00000000..3fb3be46
--- /dev/null
+++ b/tests/nationalid/test_LUX.py
@@ -0,0 +1,24 @@
+from unittest import TestCase, main
+
+from idnumbers.nationalid import LUX
+
+
+class TestLUXValidation(TestCase):
+    def test_normal_case(self):
+        self.assertTrue(LUX.NationalID.validate('1893120105732'))
+
+    def test_error_case(self):
+        self.assertFalse(LUX.NationalID.validate('1893120105733'))
+
+    def test_parse(self):
+        result = LUX.NationalID.parse('1893120105732')
+        self.assertEqual(1893, result['yyyymmdd'].year)
+        self.assertEqual(12, result['yyyymmdd'].month)
+        self.assertEqual(1, result['yyyymmdd'].day)
+        self.assertEqual('057', result['sn'])
+        self.assertEqual(3, result['checksum1'])
+        self.assertEqual(2, result['checksum2'])
+
+
+if __name__ == '__main__':
+    main()