DEV Community

Marcus Hale
Marcus Hale

Posted on

How I Batch-Stamp and Rename 40 Invoices in One Go

Every month, my clients' suppliers send me a folder's worth of invoices as PDFs. The filenames are chaos: INV_final2.pdf, scan0003.pdf, document (1).pdf. Before any of them can go to the bookkeeper, each one needs two things: a "RECEIVED" stamp with the date, and a filename a human can search later — my pattern is YYYY-MM-DD_vendor_total.pdf.

Doing this by hand for ~40 files took me about an hour of opening, stamping, saving, renaming. Now the mechanical part is a script, and my job is a 10-minute review pass. Here's the setup, including the parts that broke.

The pipeline

Three folders: inbox/ (originals, never touched), work/ (script output), review/ (files the script couldn't handle, which I fix manually). The rule that saves me monthly: the script only reads from inbox/ and only writes to work/. No in-place edits, ever. If a run goes wrong, delete work/ and re-run.

Step 1: Extract the data from the text layer

Most invoices are born-digital PDFs, so the vendor name, date, and total are sitting in the text layer. I pull them with pdfplumber and a couple of regexes:

import re, pdfplumber

def extract(path):
    with pdfplumber.open(path) as pdf:
        text = "\n".join(p.extract_text() or "" for p in pdf.pages)
    total = re.search(r"Total due[:\s]*\$?\s*([\d,]+\.\d{2})", text, re.I)
    date  = re.search(r"(\d{4})-(\d{2})-(\d{2})", text)
    return {
        "total": total.group(1).replace(",", "") if total else None,
        "date": date.group(0) if date else None,
        "text": text,
    }
Enter fullscreen mode Exit fullscreen mode

Vendor names come from a small lookup dict I maintain ({"ACME LOGISTICS": "acme-logistics", ...}) matched against the first lines of text. New supplier? The script doesn't guess — it flags the file for review/ and I add one line to the dict. That dict is boring and it's the most valuable part of the whole system.

Step 2: Build the stamp as its own tiny PDF

Don't draw the stamp into the invoice with a graphics library page by page — generate one transparent stamp PDF (RECEIVED 2026-10-05, red, slightly rotated) and reuse it as an overlay:

# stamp.py — run once per month with the received date
from reportlab.pdfgen import canvas
from reportlab.lib.colors import HexColor

c = canvas.Canvas("stamp.pdf", pagesize=(220, 60))
c.setStrokeColor(HexColor("#C0392B")); c.setFillColor(HexColor("#C0392B"))
c.setLineWidth(2); c.rect(4, 4, 212, 52)
c.setFont("Helvetica-Bold", 15)
c.drawCentredString(110, 36, "RECEIVED")
c.setFont("Helvetica", 11)
c.drawCentredString(110, 18, "2026-10-05")
c.save()
Enter fullscreen mode Exit fullscreen mode

Step 3: Overlay and rename with pikepdf

from pikepdf import Pdf, Rectangle

stamp = Pdf.open("stamp.pdf").pages[0]
with Pdf.open(src) as pdf:
    page = pdf.pages[0]
    page.add_overlay(stamp, Rectangle(380, 730, 600, 790))
    pdf.save(dst)  # dst = work/2026-10-02_acme-logistics_1240.00.pdf
Enter fullscreen mode Exit fullscreen mode

Placement is a Rectangle in the top-right of page 1 only — that's where every supplier leaves white space, and stamping all pages was overkill for an internal processing mark. (This is a bookkeeping stamp, not a legal one; anything that needs an audit trail gets handled in the accounting system, not burned into the PDF.)

Where it breaks — the review folder is the point

Roughly a quarter of my invoices are scans: flat images, no text layer, the extractor returns nothing. Early on I tried to make the script "smart" about these. That was a mistake — silent misreads are worse than no reads. Now anything without a usable text layer, a missing total, or an unknown vendor goes straight to review/ untouched.

For the scans I run one OCR pass — I use PDFLean's OCR tool for this, since the files are already leaving my machine nowhere else that month — then drop the OCR'd copies back into inbox/ and re-run the script. On a typical month that's 8–10 files, and the second pass usually clears all but one or two genuinely mangled scans (skewed, stamped over the total by the supplier's own stamp, that kind of thing). Those last ones I name by hand. It's fine. A workflow that handles 38 of 40 and clearly shows you the other 2 beats a workflow that "handles" all 40 and hides its guesses.

Other failure modes worth knowing:

  • Locale formats. A European supplier writes 1.240,00. My regex reads that as 1.240. The review pass catches it because the renamed total looks absurd against the invoice count — but only because I eyeball the list before sending. Keep the review pass.
  • Credit notes have negative totals and the word "Total" next to a positive number. I filename them with a -credit suffix by hand; automating that distinction wasn't worth it.
  • Duplicate submissions. Same invoice sent twice by two people at the client. Identical file size + identical total is my cheap dedupe signal in the summary printout.

The part I didn't automate

The script ends by printing a summary: filename, vendor, total, one line per invoice. I read that list against the bank feed before anything goes to the bookkeeper. That's the 10 minutes. Everything before it — the opening, stamping, saving, renaming — was never judgment work, it was just slow. That's the line I try to draw in all my document workflows: automate the mechanical pass completely, and make the exceptions louder, not quieter.

Full script (minus my vendor dict) is about 120 lines. The monthly cost of running it is zero and one coffee, most of which I spend reading the summary anyway.

Top comments (0)