* * * GUIDES * * *

Parse Indian GST invoices in Python.

UPDATED

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.80

04 / 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.