From 62bfbe050148b8f4cf47efc648075858dc7ca498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20Elbistan?= Date: Sat, 12 Sep 2026 20:51:55 +0300 Subject: [PATCH] feat: add Turkish Tax ID (TR_TAX_ID / VKN) recognizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TrTaxIdRecognizer, the 10-digit counterpart to TrNationalIdRecognizer (#1995): the Vergi Kimlik Numarası issued by the Revenue Administration (GIB) to legal entities and to individuals not eligible for the 11-digit TCKN. Same shape as the TCKN recognizer -- pattern match, context words, and its own checksum, distinct from the TCKN's. Disabled by default, following the existing convention for country-specific recognizers. COUNTRY_CODE = "tr" and the matching default_recognizers.yaml country_code entry are set from the start, so this needs no follow-up migration the way TrNationalIdRecognizer did in #2000. A tax_id column in a Turkish system genuinely mixes VKN (companies) and TCKN (sole traders) -- both are digit strings of adjacent length, so a checksum rather than a length check is what tells them apart. Related to #1973. Tests: 29 new cases in test_tr_tax_id_recognizer.py, covering valid/invalid checksums, wrong length, non-digits, the leading-zero case that TCKN excludes but VKN allows, context words, and the standard entity/language/country_code assertions. Verified locally: - pytest tests/test_tr_tax_id_recognizer.py: 29 passed - pytest tests/test_tr_national_id_recognizer.py tests/test_tr_license_plate_recognizer.py tests/test_recognizer_registry.py: 103 passed (no regression) - ruff check on all touched files: clean --- CHANGELOG.md | 1 + docs/supported_entities.md | 1 + .../conf/default_recognizers.yaml | 7 + .../predefined_recognizers/__init__.py | 4 + .../country_specific/turkey/__init__.py | 2 + .../turkey/tr_tax_id_recognizer.py | 115 ++++++++++++ .../tests/test_tr_tax_id_recognizer.py | 166 ++++++++++++++++++ 7 files changed, 296 insertions(+) create mode 100644 presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/turkey/tr_tax_id_recognizer.py create mode 100644 presidio-analyzer/tests/test_tr_tax_id_recognizer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e40d9dab5..a2a8718c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Analyzer #### Added +- Added `TrTaxIdRecognizer` for the Turkish Tax Identification Number (`TR_TAX_ID` / VKN), a 10-digit identifier issued by the Revenue Administration (GİB), using pattern match, context, and GİB checksum validation. Disabled by default. - Added `UuidRecognizer` (generic, entity type `UUID`) to detect UUIDs in the standard 8-4-4-4-12 hyphenated hexadecimal format, covering RFC 4122 versions 1-5 and RFC 9562 versions 6-8. Validates version and variant nibbles and filters the nil UUID to reduce false positives. - South African ID number (`ZA_ID_NUMBER`) recognizer for the 13-digit national identity number, using pattern matching, context words, birth-date validation, and Luhn checksum validation. Disabled by default. - South African recognizers for `ZA_PASSPORT`, `ZA_INCOME_TAX_NUMBER`, `ZA_DRIVER_LICENSE`, `ZA_VAT_NUMBER`, `ZA_COMPANY_REGISTRATION`, `ZA_TRAFFIC_REGISTER_NUMBER`, `ZA_LICENSE_PLATE`, `ZA_MOBILE_NUMBER`, and `ZA_TELEPHONE_NUMBER`. All disabled by default. diff --git a/docs/supported_entities.md b/docs/supported_entities.md index 209fbc06ea..aa8cc36fb3 100644 --- a/docs/supported_entities.md +++ b/docs/supported_entities.md @@ -171,6 +171,7 @@ For more information, refer to the [adding new recognizers documentation](analyz |------------|---------------------------------------------------------------------------------------------------------|------------------------------------------| | TR_NATIONAL_ID | The Turkish National Identification Number (TCKN) is a unique 11-digit number issued to all Turkish citizens. | Pattern match, context and checksum. | | TR_LICENSE_PLATE | Turkish vehicle license plate (plaka): 2-digit province code (01–81), 1–3 letters (A–Z, excluding Q, W, X), and 2–4 digits. Standard civilian format only. Legal basis: KTK Madde 23. | Pattern match, context and province code validation. | +| TR_TAX_ID | The Turkish Tax Identification Number (VKN) is a 10-digit number issued by the Revenue Administration (GİB) to legal entities and to individuals not eligible for the 11-digit TCKN. | Pattern match, context and checksum. | ### Philippines diff --git a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml index 90fc678560..20845fd54d 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml @@ -487,6 +487,13 @@ recognizers: enabled: false country_code: tr + - name: TrTaxIdRecognizer + supported_languages: + - tr + type: predefined + enabled: false + country_code: tr + - name: PhUmidRecognizer supported_languages: - en diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py index 285a5a21d2..0872381d68 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py @@ -139,6 +139,9 @@ from .country_specific.turkey.tr_national_id_recognizer import ( TrNationalIdRecognizer, ) +from .country_specific.turkey.tr_tax_id_recognizer import ( + TrTaxIdRecognizer, +) # UK recognizers from .country_specific.uk.uk_driving_licence_recognizer import ( @@ -295,6 +298,7 @@ "ThTninRecognizer", "TrLicensePlateRecognizer", "TrNationalIdRecognizer", + "TrTaxIdRecognizer", "SePersonnummerRecognizer", "ZaCompanyRegistrationRecognizer", "ZaDriverLicenseRecognizer", diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/turkey/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/turkey/__init__.py index 47327d3a6a..126c3ac8e0 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/turkey/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/turkey/__init__.py @@ -2,8 +2,10 @@ from .tr_license_plate_recognizer import TrLicensePlateRecognizer from .tr_national_id_recognizer import TrNationalIdRecognizer +from .tr_tax_id_recognizer import TrTaxIdRecognizer __all__ = [ "TrLicensePlateRecognizer", "TrNationalIdRecognizer", + "TrTaxIdRecognizer", ] diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/turkey/tr_tax_id_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/turkey/tr_tax_id_recognizer.py new file mode 100644 index 0000000000..fb6dd51c0b --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/country_specific/turkey/tr_tax_id_recognizer.py @@ -0,0 +1,115 @@ +from typing import List, Optional, Tuple, Union + +from presidio_analyzer import EntityRecognizer, Pattern, PatternRecognizer + + +class TrTaxIdRecognizer(PatternRecognizer): + """ + Recognize Turkish Tax Identification Number (Vergi Kimlik Numarası / VKN). + + The Turkish Tax ID is a 10-digit number, issued by the Revenue + Administration (Gelir İdaresi Başkanlığı / GİB) to every legal entity and + to individuals who are not eligible for the 11-digit National ID (TCKN) -- + a foreign national doing business in Turkey, for example. It carries its + own checksum, distinct from the TCKN's: + + - For i in 0..8 (0-indexed): tmp = (digit[i] + (9 - i)) % 10 + - If tmp == 9, it contributes 9 to the running total; otherwise it + contributes (tmp * 2 ** (9 - i)) % 9 + - The 10th digit must equal (10 - total % 10) % 10 + + Reference: the algorithm is undocumented by GİB itself but is the one + implemented by GİB's own e-Fatura / e-Devlet integrations and widely + reproduced in Turkish accounting and ERP software; see + https://www.gib.gov.tr/ (Gelir İdaresi Başkanlığı). + + A `tax_id` column in a Turkish ERP genuinely mixes VKN (companies) and + TCKN (sole traders): both are digit-string identifiers of adjacent + length, so a checksum -- not a length check alone -- is what tells them + apart. `TrNationalIdRecognizer` is the 11-digit counterpart. + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + :param replacement_pairs: List of tuples with potential replacement values + for different strings to be used during pattern matching. + """ + + COUNTRY_CODE = "tr" + + PATTERNS = [ + Pattern( + "TR_TAX_ID", + r"\b[0-9]{10}\b", + 0.3, + ), + ] + + CONTEXT = [ + "vergi kimlik", + "vergi kimlik no", + "vergi kimlik numarası", + "vkn", + "vergi no", + "vergi numarası", + "mükellef", + "tax id", + "tax number", + "turkish tax", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "tr", + supported_entity: str = "TR_TAX_ID", + replacement_pairs: Optional[List[Tuple[str, str]]] = None, + name: Optional[str] = None, + ): + self.replacement_pairs = replacement_pairs if replacement_pairs else [] + + patterns = patterns if patterns else self.PATTERNS + context = context if context else self.CONTEXT + super().__init__( + supported_entity=supported_entity, + patterns=patterns, + context=context, + supported_language=supported_language, + name=name, + ) + + def validate_result(self, pattern_text: str) -> Union[bool, None]: + """ + Validate the pattern logic by running checksum on a detected pattern. + + :param pattern_text: the text to validated. + Only the part in text that was detected by the regex engine + :return: A bool or None, indicating whether the validation was successful. + """ + sanitized_value = EntityRecognizer.sanitize_value( + pattern_text, self.replacement_pairs + ) + + if len(sanitized_value) != 10 or not sanitized_value.isdigit(): + return False + + return self._validate_checksum(sanitized_value) + + def _validate_checksum(self, vkn: str) -> bool: + """ + Validate a Turkish Tax ID using the GİB checksum algorithm. + + :param vkn: The VKN to validate + :return: True if checksum is valid, False otherwise + """ + digits = [int(d) for d in vkn] + + total = 0 + for i in range(9): + tmp = (digits[i] + 9 - i) % 10 + total += 9 if tmp == 9 else (tmp * (2 ** (9 - i))) % 9 + + check_digit = (10 - total % 10) % 10 + return check_digit == digits[9] diff --git a/presidio-analyzer/tests/test_tr_tax_id_recognizer.py b/presidio-analyzer/tests/test_tr_tax_id_recognizer.py new file mode 100644 index 0000000000..d4a7177dca --- /dev/null +++ b/presidio-analyzer/tests/test_tr_tax_id_recognizer.py @@ -0,0 +1,166 @@ +"""Tests for Turkish Tax ID (VKN) recognizer.""" + +import pytest +from presidio_analyzer.predefined_recognizers import TrTaxIdRecognizer + +from tests import assert_result_within_score_range + + +@pytest.fixture(scope="module") +def recognizer(): + """Create a Turkish VKN recognizer instance for testing.""" + return TrTaxIdRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + """Return the Turkish VKN entity type for testing.""" + return ["TR_TAX_ID"] + + +@pytest.mark.parametrize( + "text, expected_len, expected_positions, expected_score_ranges", + [ + # Valid VKNs with correct checksum + ("5260181599", 1, ((0, 10),), ((0.5, 1.0),),), + ("0830166136", 1, ((0, 10),), ((0.5, 1.0),),), + ("1860913903", 1, ((0, 10),), ((0.5, 1.0),),), + ("9960308245", 1, ((0, 10),), ((0.5, 1.0),),), + ("6281948215", 1, ((0, 10),), ((0.5, 1.0),),), + ("9935181908", 1, ((0, 10),), ((0.5, 1.0),),), + ("9378657978", 1, ((0, 10),), ((0.5, 1.0),),), + ("5432319485", 1, ((0, 10),), ((0.5, 1.0),),), + # Valid VKNs in sentences + ( + "Vergi Kimlik No: 5260181599", + 1, + ((17, 27),), + ((0.5, 1.0),), + ), + ( + "Sirketin VKN numarasi 0830166136 olarak tescil edilmistir.", + 1, + ((22, 32),), + ((0.5, 1.0),), + ), + # Multiple valid VKNs + ( + "Birinci firma: 5260181599, ikinci firma: 0830166136", + 2, + ((15, 25), (41, 51),), + ((0.5, 1.0), (0.5, 1.0),), + ), + # Invalid VKNs - wrong checksum + ("5260181598", 0, (), (),), + ("0830166130", 0, (), (),), + ("6281948210", 0, (), (),), + ("9378657970", 0, (), (),), + # Invalid VKNs - wrong length + ("123456789", 0, (), (),), + ("12345678901", 0, (), (),), + # Invalid VKNs - non-digits + ("abcdefghij", 0, (), (),), + # Context enhancement + ( + "Turkish tax id 1860913903", + 1, + ((15, 25),), + ((0.5, 1.0),), + ), + ( + "Mukellef vergi numarasi 9960308245", + 1, + ((24, 34),), + ((0.5, 1.0),), + ), + ], +) +def test_when_vkn_in_text_then_all_vkns_found( + text, + expected_len, + expected_positions, + expected_score_ranges, + recognizer, + entities, + max_score, +): + """Test that Turkish VKN recognizer correctly identifies VKNs.""" + results = recognizer.analyze(text, entities) + assert len(results) == expected_len + + for res, (st_pos, fn_pos), (st_score, fn_score) in zip( + results, expected_positions, expected_score_ranges + ): + if fn_score == "max": + fn_score = max_score + assert_result_within_score_range( + res, entities[0], st_pos, fn_pos, st_score, fn_score + ) + + +def test_validate_result_with_valid_vkn(recognizer): + """Test validate_result method with valid VKNs.""" + assert recognizer.validate_result("5260181599") is True + assert recognizer.validate_result("0830166136") is True + assert recognizer.validate_result("1860913903") is True + assert recognizer.validate_result("9960308245") is True + assert recognizer.validate_result("6281948215") is True + assert recognizer.validate_result("9935181908") is True + assert recognizer.validate_result("9378657978") is True + assert recognizer.validate_result("5432319485") is True + + +def test_validate_result_with_leading_zero(recognizer): + """Unlike the 11-digit TCKN, a VKN may legitimately start with 0.""" + assert recognizer.validate_result("0830166136") is True + + +def test_validate_result_with_wrong_checksum(recognizer): + """Test validate_result method with wrong checksum.""" + assert recognizer.validate_result("5260181598") is False + assert recognizer.validate_result("0830166130") is False + assert recognizer.validate_result("6281948210") is False + assert recognizer.validate_result("9378657970") is False + + +def test_validate_result_with_wrong_length(recognizer): + """Test validate_result method with wrong length.""" + assert recognizer.validate_result("123456789") is False + assert recognizer.validate_result("12345678901") is False + + +def test_validate_result_with_non_digits(recognizer): + """Test validate_result method with non-digit characters.""" + assert recognizer.validate_result("abcdefghij") is False + + +def test_context_words(recognizer): + """Test that context words are properly set.""" + expected_context = [ + "vergi kimlik", + "vergi kimlik no", + "vergi kimlik numarası", + "vkn", + "vergi no", + "vergi numarası", + "mükellef", + "tax id", + "tax number", + "turkish tax", + ] + assert recognizer.context == expected_context + + +def test_supported_entity(recognizer): + """Test that supported entity is correctly set.""" + assert recognizer.supported_entities == ["TR_TAX_ID"] + + +def test_supported_language(recognizer): + """Test that supported language is correctly set.""" + assert recognizer.supported_language == "tr" + + +def test_country_code(recognizer): + """Test that the country code is correctly set.""" + assert recognizer.COUNTRY_CODE == "tr"