Repository: identique/idnumbers (language: python)

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


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 9be62611..36c21390 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,7 @@ print(id_data['citizenship'])
 * CHL - Chile
 * CHN - China
 * ESP - Spain
+* FIN - Finland
 * FRA - France
 * GBR - United Kingdom of Great Britain and Northern Ireland
 * HKG - Hong Kong
diff --git a/idnumbers/nationalid/FIN.py b/idnumbers/nationalid/FIN.py
new file mode 100644
index 00000000..d30f0bfb
--- /dev/null
+++ b/idnumbers/nationalid/FIN.py
@@ -0,0 +1,109 @@
+import re
+from datetime import date
+from types import SimpleNamespace
+from typing import Literal, Optional, TypedDict, get_args
+from .util import validate_regexp
+from .constant import Gender
+
+
+CHECKSUM_TYPE = Literal['0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
+                        'A', 'B', 'C', 'D', 'E', 'F', 'H', 'J', 'K', 'L',
+                        'M', 'N', 'P', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
+                        'Y']
+"""literal for checksum type"""
+
+
+class ParseResult(TypedDict):
+    """The parse result of Finland personal identity code, HETU"""
+    yyyymmdd: date
+    """Birthday"""
+    gender: Gender
+    """Gender, male or female"""
+    sn: str
+    """The serial number born at the same date"""
+    checksum: CHECKSUM_TYPE
+    """checksum code in str"""
+
+
+class PersonalIdentityCode:
+    """
+    Finland personal identity code, HETU
+    https://en.wikipedia.org/wiki/National_identification_number#Finland
+    """
+    METADATA = SimpleNamespace(**{
+        'iso3166_alpha2': 'FI',
+        'min_length': 11,
+        'max_length': 11,
+        'parsable': True,
+        'checksum': True,
+        'regexp': re.compile(r'^(?P<dd>\d{2})(?P<mm>\d{2})(?P<yy>\d{2})'
+                             r'(?P<century>[-+ABCDEFUVWXY])'
+                             r'(?P<sn>\d{3})'
+                             r'(?P<check>[0-9A-Z])$')
+    })
+
+    DOB_BASE_MAP = {
+        '+': 1800,
+        '-': 1900,
+        'U': 1900,
+        'V': 1900,
+        'W': 1900,
+        'X': 1900,
+        'Y': 1900,
+        'A': 2000,
+        'B': 2000,
+        'C': 2000,
+        'D': 2000,
+        'E': 2000,
+        'F': 2000,
+    }
+    """ The century map for id """
+
+    CHECKSUM_LIST = list(get_args(CHECKSUM_TYPE))
+    """ possible checksum characteres """
+
+    @staticmethod
+    def validate(id_number: str) -> bool:
+        """
+        Validate the FIN id number
+        """
+        if not validate_regexp(id_number, PersonalIdentityCode.METADATA.regexp):
+            return False
+        return PersonalIdentityCode.parse(id_number) is not None
+
+    @staticmethod
+    def parse(id_number: str) -> Optional[ParseResult]:
+        """ parse the FIN HETU id"""
+        match_obj = PersonalIdentityCode.METADATA.regexp.match(id_number)
+        if not match_obj:
+            return None
+        elif not PersonalIdentityCode.checksum(id_number):
+            return None
+        yy = int(match_obj.group('yy'))
+        mm = int(match_obj.group('mm'))
+        dd = int(match_obj.group('dd'))
+        century = match_obj.group('century')
+        sn = match_obj.group('sn')
+        yyyy_base = PersonalIdentityCode.DOB_BASE_MAP[century]
+        return {
+            'yyyymmdd': date(yyyy_base + yy, mm, dd),
+            'gender': Gender.MALE if int(sn) % 2 == 1 else Gender.FEMALE,
+            'sn': sn,
+            'checksum': match_obj.group('check')
+        }
+
+    @staticmethod
+    def checksum(id_number: str) -> bool:
+        """check if the ID valid against its checksum"""
+        match_obj = PersonalIdentityCode.METADATA.regexp.match(id_number)
+        if not match_obj:
+            return False
+        numbers = int(match_obj.group('dd') + match_obj.group('mm') + match_obj.group('yy') + match_obj.group('sn'))
+        check_digit = PersonalIdentityCode.CHECKSUM_LIST[numbers % 31]
+        return match_obj.group('check') == check_digit
+
+
+NationalID = PersonalIdentityCode
+"""alias of PersonalIdentityCode"""
+HETU = PersonalIdentityCode
+"""alias of PersonalIdentityCode"""


Test patch:
diff --git a/idnumbers/nationalid/test_FIN.py b/idnumbers/nationalid/test_FIN.py
new file mode 100644
index 00000000..f59a644f
--- /dev/null
+++ b/idnumbers/nationalid/test_FIN.py
@@ -0,0 +1,28 @@
+from unittest import TestCase, main
+
+from idnumbers.nationalid import FIN
+from idnumbers.nationalid.constant import Gender
+
+
+class TestKORValidation(TestCase):
+    def test_normal_case(self):
+        self.assertTrue(FIN.PersonalIdentityCode.validate('131052-308T'))
+        self.assertTrue(FIN.PersonalIdentityCode.validate('240147-632T'))
+        self.assertTrue(FIN.PersonalIdentityCode.validate('280378-999U'))
+
+    def test_error_case(self):
+        self.assertFalse(FIN.PersonalIdentityCode.validate('131052-308t'))
+        self.assertFalse(FIN.PersonalIdentityCode.validate('131052-308A'))
+
+    def test_parse(self):
+        result = FIN.PersonalIdentityCode.parse('131052-308T')
+        self.assertEqual(1952, result['yyyymmdd'].year)
+        self.assertEqual(10, result['yyyymmdd'].month)
+        self.assertEqual(13, result['yyyymmdd'].day)
+        self.assertEqual(Gender.FEMALE, result['gender'])
+        self.assertEqual('308', result['sn'])
+        self.assertEqual('T', result['checksum'])
+
+
+if __name__ == '__main__':
+    main()