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
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
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}")
Run it:
python gst_invoice.py items.csv
# invoice.pdf written - grand total Rs 70,800.00
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)