""" InvoiceForge AI — validators/gst_validator.py GSTIN format and checksum validation with state code lookup. """ from __future__ import annotations import re from dataclasses import dataclass # ───────────────────────────────────────────────────────────────────────────── # CONSTANTS # ───────────────────────────────────────────────────────────────────────────── GSTIN_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" STATE_CODES: dict[str, str] = { "01": "Jammu & Kashmir", "02": "Himachal Pradesh", "03": "Punjab", "04": "Chandigarh", "05": "Uttarakhand", "06": "Haryana", "07": "Delhi", "08": "Rajasthan", "09": "Uttar Pradesh", "10": "Bihar", "11": "Sikkim", "12": "Arunachal Pradesh", "13": "Nagaland", "14": "Manipur", "15": "Mizoram", "16": "Tripura", "17": "Meghalaya", "18": "Assam", "19": "West Bengal", "20": "Jharkhand", "21": "Odisha", "22": "Chhattisgarh", "23": "Madhya Pradesh", "24": "Gujarat", "25": "Daman & Diu", "26": "Dadra & Nagar Haveli", "27": "Maharashtra", "28": "Andhra Pradesh (Old)", "29": "Karnataka", "30": "Goa", "31": "Lakshadweep", "32": "Kerala", "33": "Tamil Nadu", "34": "Puducherry", "35": "Andaman & Nicobar Islands", "36": "Telangana", "37": "Andhra Pradesh", } GSTIN_PATTERN = re.compile( r"^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z\d]Z[A-Z\d]$" ) # ───────────────────────────────────────────────────────────────────────────── # DATA CLASS # ───────────────────────────────────────────────────────────────────────────── @dataclass class GSTINValidationResult: """Result of GSTIN validation.""" gstin: str is_valid: bool state_code: str state_name: str pan: str entity_type: str # P=Proprietor, C=Company, etc. checksum_valid: bool errors: list[str] # ───────────────────────────────────────────────────────────────────────────── # VALIDATOR # ───────────────────────────────────────────────────────────────────────────── class GSTValidator: """ Validates GSTIN numbers for format correctness and checksum integrity. """ def validate(self, gstin: str) -> GSTINValidationResult: """ Full GSTIN validation. Args: gstin: 15-character GSTIN string (uppercase, no spaces). Returns: GSTINValidationResult with all validation details. """ errors: list[str] = [] gstin = gstin.strip().upper() # ── Length check ─────────────────────────────────────────────────── if len(gstin) != 15: errors.append(f"GSTIN must be 15 characters; got {len(gstin)}.") return self._fail_result(gstin, errors) # ── Pattern check ────────────────────────────────────────────────── if not GSTIN_PATTERN.match(gstin): errors.append("GSTIN format invalid. Expected: 2D 5A 4D 1A 1Z_ Z 1_") return self._fail_result(gstin, errors) # ── State code check ─────────────────────────────────────────────── state_code = gstin[:2] state_name = STATE_CODES.get(state_code, "") if not state_name: errors.append(f"Unknown state code: {state_code}.") # ── PAN embedded ─────────────────────────────────────────────────── pan = gstin[2:12] pan_valid = bool(re.match(r"^[A-Z]{5}\d{4}[A-Z]$", pan)) if not pan_valid: errors.append(f"Embedded PAN invalid: {pan}.") # ── Entity type ──────────────────────────────────────────────────── entity_type_char = gstin[11] entity_type = _entity_type_label(entity_type_char) # ── Z check ──────────────────────────────────────────────────────── if gstin[13] != "Z": errors.append("Position 14 (index 13) must be 'Z'.") # ── Checksum ─────────────────────────────────────────────────────── checksum_valid = self._verify_checksum(gstin) if not checksum_valid: errors.append("GSTIN checksum digit is incorrect.") is_valid = len(errors) == 0 return GSTINValidationResult( gstin=gstin, is_valid=is_valid, state_code=state_code, state_name=state_name, pan=pan, entity_type=entity_type, checksum_valid=checksum_valid, errors=errors, ) def validate_batch(self, gstins: list[str]) -> list[GSTINValidationResult]: """Validate a list of GSTINs and return results in the same order.""" return [self.validate(g) for g in gstins] @staticmethod def _verify_checksum(gstin: str) -> bool: """Modulo-36 weighted checksum (GST circular reference algorithm).""" factor = 2 total = 0 for char in gstin[:-1]: idx = GSTIN_CHARS.find(char) if idx < 0: return False val = idx * factor total += (val // 36) + (val % 36) factor = 3 if factor == 2 else 2 check_val = (36 - (total % 36)) % 36 expected = GSTIN_CHARS[check_val] return expected == gstin[-1] @staticmethod def _fail_result(gstin: str, errors: list[str]) -> GSTINValidationResult: return GSTINValidationResult( gstin=gstin, is_valid=False, state_code="", state_name="", pan="", entity_type="", checksum_valid=False, errors=errors, ) def _entity_type_label(char: str) -> str: """Map entity type character to a human-readable label.""" mapping = { "P": "Proprietorship", "C": "Company", "H": "HUF", "F": "Firm", "A": "AOP/BOI", "T": "Trust/AOP", "B": "Local Authority", "L": "Local Authority", "J": "Artificial Juridical Person", "G": "Government", } return mapping.get(char, f"Entity Type {char}")