Freelancers, contractors, and small businesses frequently need to issue invoices that customers can pay quickly. Manually creating PDFs in Word or Google D
Freelancers, contractors, and small businesses frequently need to issue invoices that customers can pay quickly. Manually creating PDFs in Word or Google Docs is slow, error-prone, and rarely includes a scannable payment link. When a client receives an invoice by email, they often have to open a banking app, type in an IBAN, and manually enter the amount — a process that leads to typos, delays, and unpaid invoices.
This script solves that by generating a professional PDF invoice from two simple JSON files (client details and line items) and embedding a QR code that encodes a payment URI. The customer scans the QR with their banking app and the payment details are auto-filled, reducing friction and payment time significantly. The script is designed for automation: it can be called from a cron job, a webhook, or a CI pipeline, and its behavior is configurable via environment variables.
The script follows a straightforward ETL-style pipeline: load → validate → render → persist. It reads client.json and items.json using json.load(), validates required fields, computes totals with Decimal arithmetic (avoiding float rounding bugs), and builds a reportlab canvas-based PDF. A QR code is generated with the qrcode library and embedded as a raster image on the canvas.
The QR payload follows the EPC069-12 (European Payments Council) standard, which is widely supported by European banking apps (SEPA). For non-EUR currencies, the script falls back to a simpler bitcoin:-style or plain payto:// URI, so the QR is still useful. The design uses a small InvoiceData dataclass for type safety, a render_invoice() function that owns all layout logic, and a main() that wires argument parsing to the pipeline. Errors raise typed exceptions that map to non-zero exit codes so it plays nicely with shell scripts and CI.
#!/usr/bin/env python3
"""
Invoice Generator with QR Payment Code.
Reads client and item JSON files, computes totals, renders a PDF invoice
with an embedded QR code containing a scannable payment URI, and writes
the result to disk.
Usage:
python3 invoice_gen.py --client client.json --items items.json \
--currency EUR --output invoice_001.pdf
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from dataclasses import dataclass, field
from datetime import date, timedelta
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
from typing import Any
import qrcode
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
# ---------------------------------------------------------------------------
# Configuration (all overridable via environment variables)
# ---------------------------------------------------------------------------
COMPANY_NAME = os.environ.get("INVOICE_COMPANY_NAME", "Acme Consulting Ltd.")
COMPANY_ADDRESS = os.environ.get("INVOICE_COMPANY_ADDRESS", "1 Example Street, Dublin, Ireland")
COMPANY_IBAN = os.environ.get("INVOICE_COMPANY_IBAN", "IE29AIBK93115212345678")
COMPANY_BIC = os.environ.get("INVOICE_COMPANY_BIC", "AIBKIE2D")
VAT_RATE = Decimal(os.environ.get("INVOICE_VAT_RATE", "0.23")) # 23% default
DUE_DAYS = int(os.environ.get("INVOICE_DUE_DAYS", "30"))
DEFAULT_CURRENCY = os.environ.get("INVOICE_DEFAULT_CURRENCY", "EUR")
QR_SIZE_MM = float(os.environ.get("INVOICE_QR_SIZE_MM", "45"))
# ---------------------------------------------------------------------------
# Domain model
# ---------------------------------------------------------------------------
@dataclass
class LineItem:
description: str
quantity: Decimal
unit_price: Decimal
@property
def subtotal(self) -> Decimal:
return (self.quantity * self.unit_price).quantize(Decimal("0.01"), ROUND_HALF_UP)
@dataclass
class InvoiceData:
number: str
client_name: str
client_address: str
client_email: str
currency: str
issue_date: date
due_date: date
items: list[LineItem] = field(default_factory=list)
@property
def subtotal(self) -> Decimal:
return sum((i.subtotal for i in self.items), Decimal("0.00"))
@property
def vat(self) -> Decimal:
return (self.subtotal * VAT_RATE).quantize(Decimal("0.01"), ROUND_HALF_UP)
@property
def total(self) -> Decimal:
return (self.subtotal + self.vat).quantize(Decimal("0.01"), ROUND_HALF_UP)
# ---------------------------------------------------------------------------
# Loading & validation
# ---------------------------------------------------------------------------
class InvoiceError(Exception):
"""Raised for any user-facing invoice generation error."""
def _load_json(path: Path) -> dict[str, Any]:
if not path.exists():
raise InvoiceError(f"File not found: {path}")
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
except json.JSONDecodeError as exc:
raise InvoiceError(f"Invalid JSON in {path}: {exc}") from exc
if not isinstance(data, dict):
raise InvoiceError(f"Top-level JSON in {path} must be an object")
return data
def load_client(path: Path) -> dict[str, str]:
data = _load_json(path)
required = ("name", "address", "email")
missing = [k for k in required if not data.get(k)]
if missing:
raise InvoiceError(f"client.json missing required keys: {', '.join(missing)}")
return {k: str(data[k]) for k in required}
def load_items(path: Path) -> list[LineItem]:
data = _load_json(path)
raw_items = data.get("items")
if not isinstance(raw_items, list) or not raw_items:
raise InvoiceError("items.json must contain a non-empty 'items' array")
items: list[LineItem] = []
for idx, raw in enumerate(raw_items):
try:
items.append(
LineItem(
description=str(raw["description"]),
quantity=Decimal(str(raw["quantity"])),
unit_price=Decimal(str(raw["unit_price"])),
)
)
except (KeyError, ArithmeticError) as exc:
raise InvoiceError(f"items.json entry #{idx} is invalid: {exc}") from exc
return items
# ---------------------------------------------------------------------------
# QR payload (EPC069-12 for EUR, fallback otherwise)
# ---------------------------------------------------------------------------
def build_payment_uri(inv: InvoiceData) -> str:
"""Return a scannable payment URI suitable for a QR code."""
if inv.currency.upper() == "EUR":
# EPC069-12 SEPA Credit Transfer payload
return "\n".join(
[
"BCD", # Service tag
"002", # Version
"1", # Character set (UTF-8)
"SCT", # SEPA Credit Transfer
COMPANY_BIC, # BIC
COMPANY_NAME, # Beneficiary name
COMPANY_IBAN, # IBAN
f"EUR{inv.total}", # Amount
"", # Purpose code
"", # Remittance info (structured)
f"INV {inv.number}", # Remittance info (unstructured)
"", # Beneficiary to originator info
]
)
# Generic fallback: a payto:// URI (RFC 8905)
return (
f"payto://iban/{COMPANY_IBAN}"
f"?amount={inv.currency}:{inv.total}&message=INV%20{inv.number}"
)
def make_qr_image(uri: str, out_path: Path) -> None:
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=2,
)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img.save(out_path)
# ---------------------------------------------------------------------------
# PDF rendering
# ---------------------------------------------------------------------------
def _money(value: Decimal, currency: str) -> str:
return f"{currency} {value:,.2f}"
def render_invoice(inv: InvoiceData, output: Path, qr_image: Path) -> None:
c = canvas.Canvas(str(output), pagesize=A4)
width, height = A4
# Header
c.setFont("Helvetica-Bold", 22)
c.drawString(20 * mm, height - 25 * mm, "INVOICE")
c.setFont("Helvetica", 10)
c.drawString(20 * mm, height - 32 * mm, COMPANY_NAME)
c.drawString(20 * mm, height - 37 * mm, COMPANY_ADDRESS)
c.drawString(20 * mm, height - 42 * mm, f"IBAN: {COMPANY_IBAN} BIC: {COMPANY_BIC}")
# Invoice meta (right column)
c.setFont("Helvetica-Bold", 11)
c.drawRightString(width - 20 * mm, height - 25 * mm, f"Invoice #: {inv.number}")
c.setFont("Helvetica", 10)
c.drawRightString(width - 20 * mm, height - 30 * mm, f"Issue date: {inv.issue_date.isoformat()}")
c.drawRightString(width - 20 * mm, height - 35 * mm, f"Due date: {inv.due_date.isoformat()}")
# Bill-to block
c.setFont("Helvetica-Bold", 11)
c.drawString(20 * mm, height - 60 * mm, "Bill To:")
c.setFont("Helvetica", 10)
c.drawString(20 * mm, height - 65 * mm, inv.client_name)
for offset, line in enumerate(inv.client_address.splitlines() or [inv.client_address]):
c.drawString(20 * mm, height - (70 + offset * 5) * mm, line)
c.drawString(20 * mm, height - (70 + (len(inv.client_address.splitlines()) or 1) * 5) * mm,
inv.client_email)
# Items table
table_top = height - 100 * mm
c.setFont("Helvetica-Bold", 10)
c.drawString(20 * mm, table_top, "Description")
c.drawRightString(110 * mm, table_top, "Qty")
c.drawRightString(150 * mm, table_top, "Unit Price")
c.drawRightString(width - 20 * mm, table_top, "Subtotal")
c.line(20 * mm, table_top - 2 * mm, width - 20 * mm, table_top - 2 * mm)
c.setFont("Helvetica", 10)
y = table_top - 8 * mm
for item in inv.items:
c.drawString(20 * mm, y, item.description[:60])
c.drawRightString(110 * mm, y, f"{item.quantity:g}")
c.drawRightString(150 * mm, y, _money(item.unit_price, inv.currency))
c.drawRightString(width - 20 * mm, y, _money(item.subtotal, inv.currency))
y -= 6 * mm
# Totals block
y -= 4 * mm
c.line(120 * mm, y, width - 20 * mm, y)
y -= 6 * mm
c.drawRightString(150 * mm, y, "Subtotal:")
c.drawRightString(width - 20 * mm, y, _money(inv.subtotal, inv.currency))
y -= 6 * mm
c.drawRightString(150 * mm, y, f"VAT ({VAT_RATE * 100:.1f}%):")
c.drawRightString(width - 20 * mm, y, _money(inv.vat, inv.currency))
y -= 6 * mm
c.setFont("Helvetica-Bold", 12)
c.drawRightString(150 * mm, y, "Total:")
c.drawRightString(width - 20 * mm, y, _money(inv.total, inv.currency))
# QR code + caption
qr_size = QR_SIZE_MM * mm
qr_x, qr_y = 20 * mm, 30 * mm
c.drawImage(str(qr_image), qr_x, qr_y, width=qr_size, height=qr_size,
preserveAspectRatio=True, mask="auto")
c.setFont("Helvetica", 8)
c.drawString(qr_x, qr_y - 4 * mm, "Scan to pay")
c.drawString(qr_x, qr_y - 8 * mm, f"Amount: {_money(inv.total, inv.currency)}")
# Footer
c.setFont("Helvetica-Oblique", 8)
c.setFillColor(colors.grey)
c.drawString(20 * mm, 15 * mm,
f"Payment due by {inv.due_date.isoformat()}. Thank you for your business.")
c.showPage()
c.save()
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
def generate_invoice(
client_path: Path,
items_path: Path,
output_path: Path,
currency: str,
invoice_number: str,
) -> Path:
client = load_client(client_path)
items = load_items(items_path)
today = date.today()
inv = InvoiceData(
number=invoice_number,
client_name=client["name"],
client_address=client["address"],
client_email=client["email"],
currency=currency.upper(),
issue_date=today,
due_date=today + timedelta(days=DUE_DAYS),
items=items,
)
qr_tmp = output_path.with_suffix(".qr.png")
try:
uri = build_payment_uri(inv)
make_qr_image(uri, qr_tmp)
render_invoice(inv, output_path, qr_tmp)
finally:
# Clean up the intermediate QR image regardless of outcome
if qr_tmp.exists():
qr_tmp.unlink()
return output_path
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate a PDF invoice with a QR payment code.")
parser.add_argument("--client", required=True, type=Path, help="Path to client.json")
parser.add_argument("--items", required=True, type=Path, help="Path to items.json")
parser.add_argument("--currency", default=DEFAULT_CURRENCY, help="ISO currency code (e.g. EUR)")
parser.add_argument("--output", required=True, type=Path, help="Destination PDF path")
parser.add_argument("--number", default=None, help="Invoice number (default: INV-YYYYMMDD)")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
invoice_number = args.number or f"INV-{date.today():%Y%m%d}"
try:
path = generate_invoice(
client_path=args.client,
items_path=args.items,
output_path=args.output,
currency=args.currency,
invoice_number=invoice_number,
)
except InvoiceError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except OSError as exc:
print(f"error: filesystem problem: {exc}", file=sys.stderr)
return 3
print(f"Wrote {path} ({invoice_number}, {args.currency})")
return 0
if __name__ == "__main__":
sys.exit(main())
| Env var | Default | Purpose |
|---|---|---|
INVOICE_COMPANY_NAME |
Acme Consulting Ltd. |
Beneficiary / issuer name printed on the invoice and embedded in the QR. |
INVOICE_COMPANY_ADDRESS |
1 Example Street, Dublin, Ireland |
Multi-line issuer address. |
INVOICE_COMPANY_IBAN |
IE29AIBK93115212345678 |
IBAN the customer pays into. Used by both the SEPA QR and the fallback payto:// URI. |
INVOICE_COMPANY_BIC |
AIBKIE2D |
BIC/SWIFT — required by the EPC069-12 payload. |
INVOICE_VAT_RATE |
0.23 |
Decimal VAT rate (e.g. 0.20 for 20%). Set to 0 for VAT-exempt invoices. |
INVOICE_DUE_DAYS |
30 |
Days from issue date to due date. |
INVOICE_DEFAULT_CURRENCY |
EUR |
Used when --currency isn't passed. |
INVOICE_QR_SIZE_MM |
45 |
QR code rendered size in millimetres. |
Input JSON schemas
client.json:
{ "name": "Foo Ltd.", "address": "10 Main St\nDublin 2", "email": "billing@foo.example" }
items.json:
{ "items": [
{ "description": "Consulting hours", "quantity": 12, "unit_price": 95.00 },
{ "description": "Travel expenses", "quantity": 1, "unit_price": 240.50 }
] }
1. Standard EUR invoice:
$ python3 invoice_gen.py --client client.json --items items.json \
--currency EUR --output invoice_001.pdf
Wrote invoice_001.pdf (INV-20240315, EUR)
Produces an A4 PDF with a SEPA-compliant QR code — scanning it with a European banking app pre-fills IBAN, amount, and reference.
2. Custom invoice number and non-EUR currency:
$ python3 invoice_gen.py --client client.json --items items.json \
--currency USD --number INV-2024-0042 --output q1_invoice.pdf
Wrote q1_invoice.pdf (INV-2024-0042, USD)
Since USD isn't SEPA-eligible, the QR encodes a payto:// URI instead — still scannable by apps that support RFC 8905.
3. Batch generation in a shell loop:
$ for f in clients/*.json; do
base=$(basename "$f" .json)
python3 invoice_gen.py --client "$f" --items items.json \
--output "out/$base.pdf"
done
Wrote out/acme.pdf (INV-20240315, EUR)
Wrote out/globex.pdf (INV-20240315, EUR)
InvoiceError with the offending path and reason; exit code 2.name, address, email) → explicit error listing which keys are absent.items.json directly."quantity": "abc") → caught by Decimal's ArithmeticError.OSError is caught and reported; exit code 3..qr.png is removed in a finally block, so a rendering failure won't leave litter on disk.0 success, 2 data error, 3 I/O error — useful for cron/CI alerting.smtplib or an API like SendGrid to email it straight to client["email"] — turning the script into a one-command "send invoice" tool.reportlab coordinates for a Jinja2 + HTML/CSS template rendered via weasyprint, so designers can restyle invoices without touching Python.build_payment_uri() to emit bitcoin:, ethereum:, or Stripe Checkout URLs based on a --payment-method flag, giving clients multiple ways to pay from the same invoice.