Parse Indian GST invoices in Python.
OCR gets you the text; trusting it is the hard part. A GST invoice has two built-in checks you can run for free: the GSTIN carries its own checksum, and the line items have to add up to the net amount. Here's the full pipeline in Python, from PDF to validated fields.
01 / INSTALL AND GET A KEY
One dependency. Your first 100 pages are free, no card, at pennyocr.com/dashboard.
pip install pennyocr
export PENNYOCR_API_KEY=pk_live_...02 / READ THE INVOICE
Works on PDFs, scans and phone photos. Markdown output keeps the line-item and tax tables as tables.
from pennyocr import PennyOCR
result = PennyOCR().ocr("invoice.pdf") # markdown, tables preserved
text = result.text
print(text[:800])03 / WHAT YOU GET BACK
The page in reading order, tables as markdown. Multi-line cells are joined with <br>:
**GSTIN/UIN : 27ABCDE1234F1Z0**
| Sr No | HSN SAC | Product Description | Qty | Rate | Amount |
|---:|---|---|---:|---:|---:|
| 1 | 84733099 | CPU FAN | 3 | 120.00 | 360.00 |
| 2 | 85044029 | SMPS 450W<br>SN: 1123 | 1 | 350.00 | 350.00 |
**NET AMOUNT** 837.8004 / FIND THE GSTINS
A GSTIN is 15 characters: a 2-digit state code, the 10-character PAN, an entity number, a Z, and a check character.
import re
GSTIN_RE = re.compile(r"\b\d{2}[A-Z]{5}\d{4}[A-Z][1-9A-Z]Z[0-9A-Z]\b")
gstins = GSTIN_RE.findall(text)
print(gstins)05 / VALIDATE THEM WITH THE CHECKSUM
The check character is a mod-36 checksum over the first 14, so one misread character (an 8 read as a B, an O as a 0) fails it. Don't trust a GSTIN that fails; send the invoice for review.
CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def gstin_is_valid(gstin: str) -> bool:
"""The 15th character is a mod-36 checksum over the first 14."""
total = 0
for i, ch in enumerate(gstin[:14]):
v = CHARS.index(ch) * (2 if i % 2 else 1)
total += v // 36 + v % 36
return CHARS[(36 - total % 36) % 36] == gstin[14]
for g in gstins:
print(g, "ok" if gstin_is_valid(g) else "CHECK FAILED - send for review")06 / PULL THE LINE ITEMS
A small parser turns each markdown table into a list of dicts keyed by its header row:
def markdown_tables(md: str):
"""Yield each markdown table as a list of dicts keyed by header."""
rows = []
for line in md.splitlines() + [""]:
if line.strip().startswith("|"):
rows.append([c.strip().replace("<br>", " ") for c in line.strip().strip("|").split("|")])
elif rows:
header, body = rows[0], [r for r in rows[1:] if not set("".join(r)) <= set("-: ")]
yield [dict(zip(header, r)) for r in body]
rows = []
items = next(t for t in markdown_tables(text) if "Amount" in t[0])
for it in items:
print(it["Product Description"], it["Qty"], it["Amount"])07 / CHECK THE TOTALS
Sum the line items, add the tax, compare to the net amount. Allow a rupee for round-off. This catches a dropped row or a misread amount.
def money(s: str) -> float:
return float(re.sub(r"[^\d.]", "", s) or 0)
taxable = sum(money(it["Amount"]) for it in items)
net = money(re.search(r"NET AMOUNT\W*([\d,]+\.\d{2})", text).group(1))
gst = round(taxable * 0.18, 2) # 9% CGST + 9% SGST
print(f"taxable {taxable:.2f} + GST {gst:.2f} = {taxable + gst:.2f}, invoice says {net:.2f}")
if abs(taxable + gst - net) > 1: # allow for round-off
print("totals don't add up - send for review")Cost check: $0.00075 per page, so 1,000 one-page invoices cost 75 cents, and your first 100 pages are free. Headers vary by supplier, so match column names loosely ("Amount", "AMOUNT Rs.", "Taxable Value") in production.