stockforge-ocr / validators /gst_validator.py
gagan0716's picture
Upload folder using huggingface_hub
a67c2e8 verified
Raw
History Blame Contribute Delete
7.7 kB
"""
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}")