Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/supported_entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -295,6 +298,7 @@
"ThTninRecognizer",
"TrLicensePlateRecognizer",
"TrNationalIdRecognizer",
"TrTaxIdRecognizer",
"SePersonnummerRecognizer",
"ZaCompanyRegistrationRecognizer",
"ZaDriverLicenseRecognizer",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Original file line number Diff line number Diff line change
@@ -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]
166 changes: 166 additions & 0 deletions presidio-analyzer/tests/test_tr_tax_id_recognizer.py
Original file line number Diff line number Diff line change
@@ -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"