DEV Community

Archit Mittal
Archit Mittal

Posted on • Originally published at architmittal.com

Build a GST Invoice Generator in 87 Lines of Python

If you freelance or run a small business in India, you have probably paid for invoicing software that does one thing: multiply numbers and put them in a PDF. Today we will build that ourselves — a GST-compliant tax invoice generator that reads line items from a CSV and produces a clean PDF, in 87 lines of Python.

It handles the part everyone gets wrong: the CGST/SGST vs IGST split. Intra-state sales split the tax into equal CGST and SGST halves; inter-state sales charge a single IGST. Our script decides automatically by comparing the first two digits of the buyer's and seller's GSTINs (those digits are the state code).

What you'll need

One dependency:

pip install fpdf2
Enter fullscreen mode Exit fullscreen mode

fpdf2 is a maintained, pure-Python PDF library — no system packages, works the same on Windows, Mac, and Linux.

The input format

Keep your line items in a plain CSV. Anyone on your team can edit this in Excel:

description,hsn,qty,rate,gst_pct
Website development,998314,1,45000,18
Annual hosting,998315,1,12000,18
SSL certificate,998316,2,1500,18
Enter fullscreen mode Exit fullscreen mode

The full code

"""GST invoice generator — reads line items from CSV, outputs a PDF invoice."""
import csv
import sys
from datetime import date
from fpdf import FPDF

SELLER = {
    "name": "Acme Tech Services Pvt Ltd",
    "address": "221 MG Road, Bengaluru, KA 560001",
    "gstin": "29ABCDE1234F1Z5",
    "state_code": "29",
}


def load_items(path):
    with open(path, newline="") as f:
        return [
            {
                "desc": r["description"],
                "hsn": r["hsn"],
                "qty": float(r["qty"]),
                "rate": float(r["rate"]),
                "gst_pct": float(r["gst_pct"]),
            }
            for r in csv.DictReader(f)
        ]


def compute_totals(items, interstate):
    rows, subtotal, tax_total = [], 0.0, 0.0
    for it in items:
        taxable = it["qty"] * it["rate"]
        tax = taxable * it["gst_pct"] / 100
        subtotal += taxable
        tax_total += tax
        rows.append((it, taxable, tax))
    return rows, subtotal, tax_total


def build_pdf(rows, subtotal, tax_total, buyer, interstate, out_path):
    pdf = FPDF()
    pdf.add_page()
    pdf.set_font("Helvetica", "B", 16)
    pdf.cell(0, 10, "TAX INVOICE", align="C", new_x="LMARGIN", new_y="NEXT")
    pdf.set_font("Helvetica", size=10)
    pdf.cell(0, 6, f"{SELLER['name']} | GSTIN: {SELLER['gstin']}",
             new_x="LMARGIN", new_y="NEXT")
    pdf.cell(0, 6, SELLER["address"], new_x="LMARGIN", new_y="NEXT")
    pdf.cell(0, 6, f"Invoice date: {date.today():%d-%m-%Y}  |  Bill to: "
             f"{buyer['name']} (GSTIN: {buyer['gstin']})",
             new_x="LMARGIN", new_y="NEXT")
    pdf.ln(4)
    pdf.set_font("Helvetica", "B", 9)
    widths = (70, 20, 15, 25, 15, 25)
    for w, h in zip(widths, ("Description", "HSN", "Qty", "Rate (Rs)",
                             "GST %", "Amount (Rs)")):
        pdf.cell(w, 7, h, border=1)
    pdf.ln()
    pdf.set_font("Helvetica", size=9)
    for it, taxable, _ in rows:
        cells = (it["desc"], it["hsn"], f"{it['qty']:g}",
                 f"{it['rate']:,.2f}", f"{it['gst_pct']:g}", f"{taxable:,.2f}")
        for w, v in zip(widths, cells):
            pdf.cell(w, 7, str(v), border=1)
        pdf.ln()
    pdf.ln(3)
    pdf.set_font("Helvetica", "B", 10)
    pdf.cell(0, 6, f"Taxable value: Rs {subtotal:,.2f}",
             new_x="LMARGIN", new_y="NEXT")
    if interstate:
        pdf.cell(0, 6, f"IGST: Rs {tax_total:,.2f}",
                 new_x="LMARGIN", new_y="NEXT")
    else:
        pdf.cell(0, 6, f"CGST: Rs {tax_total / 2:,.2f}   SGST: "
                 f"Rs {tax_total / 2:,.2f}", new_x="LMARGIN", new_y="NEXT")
    pdf.cell(0, 8, f"Grand total: Rs {subtotal + tax_total:,.2f}",
             new_x="LMARGIN", new_y="NEXT")
    pdf.output(out_path)


if __name__ == "__main__":
    csv_path = sys.argv[1] if len(sys.argv) > 1 else "items.csv"
    buyer = {"name": "Globex Retail LLP", "gstin": "27PQRSX5678G1Z3"}
    interstate = buyer["gstin"][:2] != SELLER["state_code"]
    rows, subtotal, tax_total = compute_totals(load_items(csv_path), interstate)
    build_pdf(rows, subtotal, tax_total, buyer, interstate, "invoice.pdf")
    print(f"invoice.pdf written - grand total Rs {subtotal + tax_total:,.2f}")
Enter fullscreen mode Exit fullscreen mode

Run it:

python gst_invoice.py items.csv
# invoice.pdf written - grand total Rs 70,800.00
Enter fullscreen mode Exit fullscreen mode

How it works

Loading (load_items). csv.DictReader turns each CSV row into a dict keyed by the header row, so the code reads like the spreadsheet. We cast qty, rate, and gst_pct to floats up front so the math functions never deal with strings.

The tax math (compute_totals). Each line's taxable value is qty x rate, and tax is a simple percentage of that. We accumulate a subtotal and a tax total and keep the per-row figures for the PDF table. The interstate decision happens once, in the entrypoint: buyer GSTIN state code != seller state code means IGST applies. In our example the buyer's GSTIN starts with 27 (Maharashtra) and the seller's with 29 (Karnataka), so the invoice shows a single IGST line. Change the buyer's GSTIN to start with 29 and you will see it split into equal CGST and SGST halves instead — no other change needed.

The PDF (build_pdf). fpdf2's cell() API is old-school but predictable: each cell has a fixed width, border=1 draws the table lines, and new_y="NEXT" moves the cursor down a row. The widths tuple is the entire layout — adjust six numbers and the whole table reflows. Total layout code: about 40 lines, and you own every pixel of it.

Extending it

A few upgrades that each take only a handful of lines: pull SELLER and the buyer from a JSON config instead of hardcoding; add an invoice number that auto-increments from a counter file; loop over a folder of CSVs to batch-generate a month of invoices; or add pdf.image("logo.png", x=10, y=8, w=30) for letterhead branding.

One honest caveat: this covers the common single-rate service invoice. If you deal with cess, reverse charge, or e-invoicing (IRN/QR codes), those are regimes of their own — treat this as the foundation, not a compliance department.

That's a working invoice pipeline in 87 lines — CSV in, compliant PDF out, and the tax split handled correctly without a subscription.

Follow me on Twitter @automate_archit for daily AI automation tips.

Top comments (0)