Coverage for presidio_analyzer / predefined_recognizers / country_specific / india / in_gstin_recognizer.py: 91%
54 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-29 09:03 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-29 09:03 +0000
1from typing import List, Optional, Tuple
3from presidio_analyzer import Pattern, PatternRecognizer
6class InGstinRecognizer(PatternRecognizer):
7 """
8 Recognizes Indian Goods and Services Tax Identification Number ("GSTIN").
10 The GSTIN is a 15-character identifier with the following structure:
11 - First 2 digits: State code (01-37)
12 - Next 10 digits: PAN of the entity
13 - 13th digit: Registration number for same PAN in the state
14 - 14th digit: 'Z'
15 - 15th digit: Checksum
17 Reference: https://www.gst.gov.in/
18 This recognizer identifies GSTIN using regex and context words.
20 :param patterns: List of patterns to be used by this recognizer
21 :param context: List of context words to increase confidence in detection
22 :param supported_language: Language this recognizer supports
23 :param supported_entity: The entity this recognizer can detect
24 :param replacement_pairs: List of tuples with potential replacement values
25 for different strings to be used during pattern matching.
26 This can allow a greater variety in input, for example by removing dashes or spaces.
27 """
29 PATTERNS = [
30 Pattern(
31 "GSTIN (High)",
32 r"\b((?:0[1-9]|[1-3][0-7])[A-Za-z0-9]{10}[A-Za-z0-9]{1}Z[A-Za-z0-9]{1})\b",
33 0.8,
34 ),
35 Pattern(
36 "GSTIN (Medium)",
37 r"\b((?:0[1-9]|[1-3][0-7])[A-Za-z0-9]{11}Z[A-Za-z0-9]{1})\b",
38 0.4,
39 ),
40 Pattern(
41 "GSTIN (Low)",
42 r"\b([0-9]{2}[A-Za-z0-9]{11}Z[A-Za-z0-9]{1})\b",
43 0.1,
44 ),
45 ]
47 CONTEXT = [
48 "gstin",
49 "gst",
50 "goods and services tax",
51 "tax identification",
52 "gst number",
53 "gst registration",
54 ]
56 def __init__(
57 self,
58 patterns: Optional[List[Pattern]] = None,
59 context: Optional[List[str]] = None,
60 supported_language: str = "en",
61 supported_entity: str = "IN_GSTIN",
62 replacement_pairs: Optional[List[Tuple[str, str]]] = None,
63 name: Optional[str] = None,
64 ):
65 self.replacement_pairs = (
66 replacement_pairs if replacement_pairs else [("-", ""), (" ", "")]
67 )
68 patterns = patterns if patterns else self.PATTERNS
69 context = context if context else self.CONTEXT
70 super().__init__(
71 supported_entity=supported_entity,
72 patterns=patterns,
73 context=context,
74 supported_language=supported_language,
75 name=name,
76 )
77 self.supported_entity = supported_entity
79 def validate_result(self, pattern_text: str) -> bool:
80 """
81 Validate the GSTIN format and structure.
83 :param pattern_text: The text pattern to validate
84 :return: True if the GSTIN is valid, False otherwise
85 """
86 sanitized_value = self._sanitize_value(pattern_text)
87 return self._validate_gstin(sanitized_value)
89 def _sanitize_value(self, text: str) -> str:
90 """Remove common separators and normalize the text."""
91 import re
93 # First, try to extract GSTIN pattern from the text
94 gstin_pattern = (
95 r"\b((?:0[1-9]|[1-3][0-7])[A-Za-z]{5}[0-9]{4}[A-Za-z]{1}"
96 r"[0-9A-Za-z]{1}Z[0-9A-Za-z]{1})\b"
97 )
98 match = re.search(gstin_pattern, text.upper())
99 if match:
100 return match.group(1)
102 # If no GSTIN pattern found, sanitize the entire text
103 sanitized = text.upper()
104 for old, new in self.replacement_pairs:
105 sanitized = sanitized.replace(old, new)
106 return sanitized
108 def _validate_gstin(self, gstin: str) -> bool:
109 """
110 Validate GSTIN structure and format.
112 :param gstin: The GSTIN string to validate
113 :return: True if valid, False otherwise
114 """
115 if len(gstin) != 15:
116 return False
118 # Check state code (first 2 digits should be 01-37)
119 state_code = gstin[:2]
120 if not state_code.isdigit() or not (1 <= int(state_code) <= 37):
121 return False
123 # Check PAN format (characters 3-12)
124 pan_part = gstin[2:12]
125 if not self._validate_pan_format(pan_part):
126 return False
128 # Check 13th character (registration number)
129 reg_number = gstin[12]
130 if not reg_number.isalnum():
131 return False
133 # Check 14th character should be 'Z'
134 if gstin[13] != "Z":
135 return False
137 # Check 15th character (checksum)
138 checksum = gstin[14]
139 if not checksum.isalnum():
140 return False
142 return True
144 def _validate_pan_format(self, pan: str) -> bool:
145 """
146 Validate PAN format within GSTIN.
148 PAN format: 5 letters + 4 digits + 1 letter
149 However, some valid PANs may have digits in the first 5 characters.
151 :param pan: The PAN part of GSTIN (10 characters)
152 :return: True if valid PAN format, False otherwise
153 """
154 if len(pan) != 10:
155 return False
157 # Check that it contains a mix of letters and digits
158 # At least 3 letters in the first 5 characters
159 first_five = pan[:5]
160 letter_count = sum(1 for c in first_five if c.isalpha())
161 if letter_count < 3:
162 return False
164 # Characters 6-9 should be digits
165 if not pan[5:9].isdigit():
166 return False
168 # 10th character should be a letter
169 if not pan[9].isalpha():
170 return False
172 return True