DevLab / finance / invoice-generator-with-qr-payment-code
FINANCE PYTHON INTERMEDIATE

Invoice Generator with QR Payment Code

Freelancers, contractors, and small businesses frequently need to issue invoices that customers can pay quickly. Manually creating PDFs in Word or Google D

Python3.10+
OSLinux / macOS
Lines~282
Packages2 deps
Install Dependencies
$ pip install reportlab qrcode
Usage
$ python3 invoice_gen.py --client client.json --items items.json --currency EUR --output invoice_001.pdf
#finance#python#intermediate#reportlab#qrcode

Invoice Generator with QR Payment Code

Problem Statement

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.

How It Works

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.

Full Script

#!/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())

Configuration

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 }
] }

Usage Examples

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)

Error Handling

  • Missing or malformed JSON → InvoiceError with the offending path and reason; exit code 2.
  • Missing required client keys (name, address, email) → explicit error listing which keys are absent.
  • Empty or malformed items array → error names the failing index so you can fix items.json directly.
  • Invalid decimal values (e.g. "quantity": "abc") → caught by Decimal's ArithmeticError.
  • Filesystem problems (read-only output dir, permission denied) → OSError is caught and reported; exit code 3.
  • QR temp file cleanup — the intermediate .qr.png is removed in a finally block, so a rendering failure won't leave litter on disk.
  • Exit codes are shell-friendly: 0 success, 2 data error, 3 I/O error — useful for cron/CI alerting.

Extending Ideas

  • Email delivery: after generating the PDF, use smtplib or an API like SendGrid to email it straight to client["email"] — turning the script into a one-command "send invoice" tool.
  • Template-driven layout: swap the hard-coded reportlab coordinates for a Jinja2 + HTML/CSS template rendered via weasyprint, so designers can restyle invoices without touching Python.
  • Persistent invoice registry: append each generated invoice to a small SQLite database (number, client, amount, issue date, paid status) so you can query outstanding invoices and auto-send reminders.
  • Multi-page support: paginate the items table when it exceeds the A4 height, and repeat the header row on each page — important for invoices with dozens of line items.
  • Crypto/other payment rails: extend 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.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com