Every Indian freelancer, agency, and small business eventually faces the same boring problem: generating GST-compliant invoices. The big SaaS tools charge ₹500–₹2000 per month for what is fundamentally a templating + arithmetic job. Let's just write it ourselves.
By the end of this post you'll have a Python script that:
- Takes a buyer's state, line items, and GST rates
- Correctly splits tax into CGST + SGST (intra-state) or IGST (inter-state) — the part most templates get wrong
- Produces a clean, printable PDF invoice with HSN codes and a proper tax breakdown
- Runs anywhere Python runs, no SaaS, no monthly fee
Total code: about 70 lines.
Why bother building this?
If you've ever used a stock invoice template, you've probably noticed it just hardcodes "CGST 9% + SGST 9%". That works for sellers and buyers in the same state. The moment you bill a client in another state, you legally owe IGST 18% instead — and the invoice has to say so. Get this wrong and your buyer can't claim input tax credit cleanly. Get it wrong at scale and your CA bills you for the cleanup.
The logic is genuinely simple. The reason people pay for it is that bundling logic + PDF rendering + line items into one tool feels like work. It isn't.
Setup
One dependency:
pip install reportlab
reportlab is a battle-tested PDF library — same one that powers a lot of Python report generators. No fonts to install, no headless browser, no Node.
The full script
Save this as gst_invoice.py:
"""
Simple GST Invoice Generator
Splits CGST/SGST or IGST automatically based on buyer's state.
"""
from datetime import date
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
)
SELLER = {
"name": "Bharat Traders Pvt Ltd",
"address": "12, MG Road, Bengaluru, Karnataka 560001",
"gstin": "29ABCDE1234F1Z5",
"state": "Karnataka",
}
def calc_gst(amount, rate, buyer_state):
"""Intra-state -> CGST+SGST; Inter-state -> IGST."""
if buyer_state.strip().lower() == SELLER["state"].lower():
half = round(amount * rate / 200, 2)
return half, half, 0.0
return 0.0, 0.0, round(amount * rate / 100, 2)
def line_totals(items, buyer):
rows, sub, cgst, sgst, igst = [], 0.0, 0.0, 0.0, 0.0
for it in items:
amt = round(it["qty"] * it["price"], 2)
c, s, i = calc_gst(amt, it["gst"], buyer["state"])
sub += amt; cgst += c; sgst += s; igst += i
rows.append([it["desc"], it["hsn"], it["qty"],
f"Rs.{it['price']:.2f}", f"{it['gst']}%",
f"Rs.{amt:.2f}", f"Rs.{c+s+i:.2f}"])
return rows, sub, cgst, sgst, igst, round(sub + cgst + sgst + igst, 2)
def build_invoice(filename, buyer, items, invoice_no):
rows, sub, cgst, sgst, igst, total = line_totals(items, buyer)
doc = SimpleDocTemplate(filename, pagesize=A4, title=f"Invoice {invoice_no}")
s = getSampleStyleSheet()
story = [
Paragraph(f"<b>TAX INVOICE — {invoice_no}</b>", s["Title"]),
Paragraph(f"Date: {date.today():%d %b %Y}", s["Normal"]),
Spacer(1, 8),
Paragraph(f"<b>From:</b> {SELLER['name']}<br/>{SELLER['address']}"
f"<br/>GSTIN: {SELLER['gstin']}", s["Normal"]),
Spacer(1, 6),
Paragraph(f"<b>Bill To:</b> {buyer['name']}<br/>{buyer['address']}"
f"<br/>GSTIN: {buyer.get('gstin', 'Unregistered')}"
f"<br/>State: {buyer['state']}", s["Normal"]),
Spacer(1, 12),
]
data = [["Item", "HSN", "Qty", "Rate", "GST", "Amount", "Tax"]] + rows + [
["", "", "", "", "Subtotal", f"Rs.{sub:.2f}", ""],
["", "", "", "", "CGST", f"Rs.{cgst:.2f}", ""],
["", "", "", "", "SGST", f"Rs.{sgst:.2f}", ""],
["", "", "", "", "IGST", f"Rs.{igst:.2f}", ""],
["", "", "", "", "TOTAL", f"Rs.{total:.2f}", ""],
]
t = Table(data, repeatRows=1)
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0b3d91")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
("FONTSIZE", (0, 0), (-1, -1), 9),
]))
story.append(t)
doc.build(story)
print(f"Generated {filename} — Grand Total Rs.{total:.2f}")
if __name__ == "__main__":
buyer = {"name": "Acme Corp", "address": "21 Park St, Kolkata 700016",
"gstin": "19XYZAB6789K1Z2", "state": "West Bengal"}
items = [
{"desc": "USB-C Cable 1m", "hsn": "8544", "qty": 50, "price": 199, "gst": 18},
{"desc": "Wireless Mouse", "hsn": "8471", "qty": 10, "price": 749, "gst": 18},
{"desc": "Notebook A5", "hsn": "4820", "qty": 100, "price": 60, "gst": 12},
]
build_invoice("invoice_INV-2026-001.pdf", buyer, items, "INV-2026-001")
Run it:
python gst_invoice.py
You'll get invoice_INV-2026-001.pdf in the same folder. Because the seller is in Karnataka and the buyer is in West Bengal, the script automatically uses IGST instead of CGST+SGST. Change the buyer's state to "Karnataka" and rerun — the split flips. That's the whole rule, encoded in five lines.
How the GST split works
The function calc_gst is the brain of this script:
if buyer_state.strip().lower() == SELLER["state"].lower():
half = round(amount * rate / 200, 2)
return half, half, 0.0 # CGST + SGST, each half the rate
return 0.0, 0.0, round(amount * rate / 100, 2) # IGST, full rate
The GST law says you charge the same total tax either way — 18% is 18%. The difference is who collects which half. For intra-state sales the centre and the state each take 9%. For inter-state sales the centre collects the full 18% as IGST and settles with the destination state later. From your invoice's perspective: same amount, different label.
The string compare is intentionally loose (strip().lower()) because in practice state names arrive from CSVs and form inputs with random capitalization and trailing spaces.
Wiring it into your workflow
A few obvious next steps once the basic version works:
Bulk invoices from a CSV. Add a pandas.read_csv("orders.csv") loop around build_invoice. One invoice per row, filenames keyed on invoice number. Useful if you're an agency closing out 30 retainer clients on the 1st of every month.
Auto-email the PDF. Pipe the generated file to smtplib with the client's email taken from the same CSV. The whole "generate + email" pipeline lands under 120 lines.
HSN/SAC lookup. Replace the hard-coded "hsn" field with a lookup against a small dict of common services (consulting = 9983, software = 9973, etc.) so you can't typo it.
Sequential numbering. Add INV-YYYY-NNNN numbering by reading the highest existing PDF in the output folder and incrementing. GST law requires invoice numbers be sequential and unbroken — easy to mess up by hand, trivial to enforce in code.
Validation. GSTINs follow a known checksum (the last character is computed from the first 14). A 10-line validator catches typos before the PDF is generated.
What you've saved
A typical GST billing SaaS in India runs ₹500–₹2000/month for the tier that allows custom branding and unlimited invoices. A solo consultant who issues 10–20 invoices a month is paying ₹6,000–₹24,000 a year for something that ran in 70 lines of Python on their laptop.
The deeper point: most "business automation" tools that get sold as products are 100-line scripts wearing a marketing layer. Once you can read Python, you stop being a customer and start being the supplier.
Follow me on Twitter @automate_archit for daily AI automation tips.
Top comments (0)