Process Invoice PDFs from Email into Excel
Accounts payable wants every supplier invoice that arrives by email listed in invoice-register.xlsx — supplier, invoice number, date, net, VAT and gross — before anyone keys it into the finance system. The attachments already land in inbox/incoming/ thanks to the email intake job. The first extraction script reads them, but the register fills up with problems: the same invoice twice because a supplier re-sent it, dates parsed as the wrong month for US-format suppliers, totals of 1.240,50 read as 1.24, a scanned invoice with every field empty, and a register file that corrupts when the job runs while someone has it open in Excel.
Root Cause
An invoice pipeline joins four steps that each fail independently, and a script that treats them as one hides every failure inside a row of blanks. Extraction depends on each supplier's layout, so one generic pattern mis-reads some suppliers and misses others. Normalisation depends on each supplier's locale — day-first or month-first dates, comma or dot decimals — which cannot be guessed from the numbers alone. Validation is what separates "extracted" from "correct", and without it a wrong total looks exactly like a right one. And the output step writes to a file people also open, where duplicates and concurrent access must be handled deliberately. Structured as separate stages with a quarantine for anything that fails a stage, the job becomes trustworthy: every PDF ends in the register or in a folder with a reason.
Minimal Diagnostic
Profile the incoming PDFs before writing extraction rules: which have a text layer, which supplier sent each, and whether the key labels are present. This tells you how many layout rules you need and which files need OCR.
# pip install pdfplumber "pandas>=2.2"
import re
from pathlib import Path
import pandas as pd
import pdfplumber
INCOMING = Path("inbox/incoming")
LABELS = {"invoice_no": r"invoice\s*(no|number|#)", "date": r"invoice\s*date|date\s*of\s*issue",
"total": r"(total\s*(due|amount)?|amount\s*due|gross)"}
def profile(folder: Path) -> pd.DataFrame:
rows = []
for pdf_path in sorted(folder.glob("*.pdf")):
try:
with pdfplumber.open(pdf_path) as pdf:
text = "\n".join((p.extract_text() or "") for p in pdf.pages[:2])
except Exception as exc:
rows.append({"file": pdf_path.name, "error": str(exc)})
continue
rows.append({
"file": pdf_path.name,
"sender_domain": pdf_path.name.split("_")[1] if "_" in pdf_path.name else "",
"chars": len(text),
**{k: bool(re.search(v, text, re.I)) for k, v in LABELS.items()},
})
return pd.DataFrame(rows)
if __name__ == "__main__":
report = profile(INCOMING)
print(report.groupby("sender_domain")[["chars", *LABELS]].agg(["mean"]).round(2).to_string())
chars invoice_no date total
sender_domain mean mean mean mean
contoso-example 1843.0 1.0 1.0 1.0
fabrikam-example 0.0 0.0 0.0 0.0
northwind-example 2210.5 1.0 0.5 1.0
Fabrikam sends scans with no text layer — they need OCR or manual entry. Northwind uses two layouts, only one with an "Invoice date" label. Contoso is consistent. That is three supplier rules and one OCR route, not one universal regex.
Fix: Supplier Rules, Locale-Aware Parsing, Validation and a Safe Register
Define extraction as a small table of per-supplier rules, parse numbers and dates with the supplier's conventions, validate the arithmetic, and append to the register only rows that pass. Changed lines carry comments.
# pip install pdfplumber "pandas>=2.2" openpyxl
import re
import shutil
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
import pdfplumber
@dataclass
class SupplierRule:
name: str
invoice_no: str
date: str
net: str
vat: str
gross: str
date_format: str # e.g. "%d/%m/%Y" or "%m/%d/%Y"
decimal_comma: bool = False
RULES = { # changed: one rule per supplier
"contoso-example": SupplierRule("Contoso Ltd", r"Invoice No\.?\s*(\S+)", r"Invoice Date\s*(\d{2}/\d{2}/\d{4})",
r"Net\s*£?([\d,]+\.\d{2})", r"VAT\s*£?([\d,]+\.\d{2})",
r"Total Due\s*£?([\d,]+\.\d{2})", "%d/%m/%Y"),
"northwind-example": SupplierRule("Northwind GmbH", r"Rechnung(?:snummer)?\s*[:#]?\s*(\S+)",
r"Datum\s*[:]?\s*(\d{2}\.\d{2}\.\d{4})", r"Netto\s*€?\s*([\d.]+,\d{2})",
r"MwSt\.?.*?€?\s*([\d.]+,\d{2})", r"Gesamt\s*€?\s*([\d.]+,\d{2})",
"%d.%m.%Y", decimal_comma=True),
}
def money(text: str, decimal_comma: bool) -> Decimal:
cleaned = text.replace(".", "").replace(",", ".") if decimal_comma else text.replace(",", "") # changed
try:
return Decimal(cleaned) # changed: exact money
except InvalidOperation as exc:
raise ValueError(f"not a number: {text!r}") from exc
def extract(pdf_path: Path) -> dict:
domain = pdf_path.name.split("_")[1] if pdf_path.name.count("_") >= 2 else ""
rule = RULES.get(domain)
if rule is None:
raise ValueError(f"no extraction rule for sender {domain!r}")
with pdfplumber.open(pdf_path) as pdf:
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
if len(text.strip()) < 50:
raise ValueError("no text layer: scanned invoice needs OCR")
def find(pattern: str, label: str) -> str:
m = re.search(pattern, text, re.I | re.S)
if not m:
raise ValueError(f"{label} not found")
return m.group(1).strip()
return {
"supplier": rule.name,
"invoice_no": find(rule.invoice_no, "invoice number"),
"invoice_date": datetime.strptime(find(rule.date, "date"), rule.date_format).date(), # changed
"net": money(find(rule.net, "net"), rule.decimal_comma),
"vat": money(find(rule.vat, "VAT"), rule.decimal_comma),
"gross": money(find(rule.gross, "gross"), rule.decimal_comma),
"source_file": pdf_path.name,
}
Decimal keeps totals exact, so the arithmetic check below does not trip over float noise like 1240.5000000001. Date formats are declared per supplier because 03/04/2026 is ambiguous and no parser can resolve it without knowing who wrote it — the same issue at scale is covered in normalize inconsistent date formats in CSV.
Validate Before Writing
# pip install "pandas>=2.2" openpyxl
from decimal import Decimal
import pandas as pd
def validate(inv: dict, register: pd.DataFrame, tolerance: Decimal = Decimal("0.02")) -> list[str]:
problems = []
if abs(inv["net"] + inv["vat"] - inv["gross"]) > tolerance:
problems.append(f"net {inv['net']} + VAT {inv['vat']} != gross {inv['gross']}")
if inv["net"] > 0 and not (Decimal("0") <= inv["vat"] / inv["net"] <= Decimal("0.27")):
problems.append(f"implausible VAT rate {inv['vat'] / inv['net']:.2%}")
age = (pd.Timestamp.today().normalize() - pd.Timestamp(inv["invoice_date"])).days
if not -3 <= age <= 400:
problems.append(f"invoice date {inv['invoice_date']} outside expected window")
duplicate = ((register["supplier"] == inv["supplier"]) &
(register["invoice_no"].astype(str) == str(inv["invoice_no"]))).any() if len(register) else False
if duplicate:
problems.append("duplicate: already in register")
return problems
A two-cent tolerance allows for per-line rounding that suppliers apply differently; anything larger is a mis-read field, usually the wrong number captured by a loose pattern. Duplicates are keyed on supplier plus invoice number, never on the file hash — a re-sent invoice is often a newly generated PDF with different bytes.
Append to the Register Safely
# pip install "pandas>=2.2" openpyxl
import os
import shutil
from pathlib import Path
import pandas as pd
REGISTER = Path("finance/invoice-register.xlsx")
COLUMNS = ["supplier", "invoice_no", "invoice_date", "net", "vat", "gross", "source_file", "processed_at"]
def load_register() -> pd.DataFrame:
if not REGISTER.exists():
return pd.DataFrame(columns=COLUMNS)
return pd.read_excel(REGISTER, dtype={"invoice_no": "string"})
def append_rows(rows: list[dict]) -> None:
current = load_register()
new = pd.DataFrame(rows, columns=COLUMNS)
for col in ("net", "vat", "gross"):
new[col] = new[col].astype(float)
combined = pd.concat([current, new], ignore_index=True)
tmp = REGISTER.with_name(f".{REGISTER.stem}.tmp.xlsx")
REGISTER.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(tmp, engine="openpyxl", date_format="yyyy-mm-dd") as writer:
combined.to_excel(writer, sheet_name="Register", index=False)
try:
os.replace(tmp, REGISTER) # atomic on the same file system
except PermissionError as exc:
raise RuntimeError("register is open in Excel; rows kept for the next run") from exc
Writing a complete new file and renaming it over the old one means the register is never half-written. On Windows the rename fails while someone has the file open, which is the correct outcome: the job keeps the rows and retries on the next run rather than corrupting the file or losing data — the error and its alternatives are covered in fix PermissionError writing Excel file.
Variant Fix: Scanned Invoices
Suppliers who send scans fail the text-layer check. Route them through OCR before extraction, then apply the same supplier rules to the OCR text. OCRmyPDF adds a text layer in place, so the rest of the pipeline stays unchanged:
# pip install ocrmypdf
from pathlib import Path
import ocrmypdf
def ensure_text_layer(pdf_path: Path, work_dir: Path) -> Path:
work_dir.mkdir(parents=True, exist_ok=True)
out = work_dir / pdf_path.name
try:
ocrmypdf.ocr(pdf_path, out, skip_text=True, language="eng+deu", progress_bar=False)
except ocrmypdf.exceptions.PriorOcrFoundError:
return pdf_path
return out
OCR text contains recognition errors — 0 for O, missing decimal separators — so the validation step matters even more for these suppliers, and their quarantine rate is worth tracking separately. Tuning OCR input quality is covered in make scanned PDFs searchable with OCRmyPDF.
The Run Loop with Quarantine
# pip install pdfplumber "pandas>=2.2" openpyxl
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
INCOMING, DONE, QUARANTINE = Path("inbox/incoming"), Path("inbox/processed"), Path("inbox/quarantine")
def run() -> dict:
register = load_register()
accepted, counts = [], {"accepted": 0, "quarantined": 0}
for pdf_path in sorted(INCOMING.glob("*.pdf")):
try:
inv = extract(pdf_path)
problems = validate(inv, register)
except Exception as exc:
problems = [str(exc)]
if problems:
QUARANTINE.mkdir(parents=True, exist_ok=True)
shutil.move(pdf_path, QUARANTINE / pdf_path.name)
(QUARANTINE / f"{pdf_path.stem}.reason.json").write_text(json.dumps(problems, indent=2))
counts["quarantined"] += 1
continue
inv["processed_at"] = datetime.now(timezone.utc).replace(tzinfo=None)
accepted.append((pdf_path, inv))
if accepted:
append_rows([inv for _, inv in accepted])
DONE.mkdir(parents=True, exist_ok=True)
for pdf_path, _ in accepted:
shutil.move(pdf_path, DONE / pdf_path.name) # move only after the register is saved
counts["accepted"] = len(accepted)
return counts
PDFs move to processed only after the register write succeeds, so a failure while saving leaves them in incoming for the next run — and the duplicate check stops them being added twice if the save actually completed.
Verification
Reconcile the run: every PDF that entered incoming must be in processed or quarantine, the register must have gained exactly as many rows as were accepted, and totals in the register must equal the sum of the accepted invoices.
# pip install "pandas>=2.2" openpyxl
from pathlib import Path
import pandas as pd
def verify_run(before_files: set[str], rows_before: int, counts: dict) -> None:
processed = {p.name for p in Path("inbox/processed").glob("*.pdf")}
quarantined = {p.name for p in Path("inbox/quarantine").glob("*.pdf")}
unaccounted = before_files - processed - quarantined
assert not unaccounted, f"PDFs with no outcome: {sorted(unaccounted)}"
register = pd.read_excel("finance/invoice-register.xlsx", dtype={"invoice_no": "string"})
assert len(register) == rows_before + counts["accepted"], "register row count mismatch"
dupes = register.duplicated(subset=["supplier", "invoice_no"], keep=False)
assert not dupes.any(), f"duplicate invoices in register: {register[dupes]['invoice_no'].tolist()}"
arithmetic = (register["net"] + register["vat"] - register["gross"]).abs() > 0.02
assert not arithmetic.any(), "register contains rows that fail net + VAT = gross"
print(f"run verified: {counts}")
Send the counts and the quarantine reasons to the accounts payable team after each run. A quarantine folder nobody looks at is just a slower way to lose invoices; the email step from email generated reports with Python fits directly here.
FAQ
Why not use a single invoice-parsing library for all suppliers? Generic invoice parsers help with common layouts, but still need validation and per-supplier fallbacks. Starting with explicit rules for your top suppliers covers most volume and keeps failures understandable.
Should the register be a database instead of Excel? For more than a few thousand invoices a year or several concurrent users, yes — write to SQLite or your finance system's import format and generate the Excel view from it. The pipeline stages stay the same.
How do I add a new supplier? Run the diagnostic on a few of their PDFs, add a rule, and run the job against those files in a test folder. Quarantine reasons will tell you exactly which field the rule misses.
What about invoices with multiple VAT rates? Extract the VAT table rows with pdfplumber's table extraction and sum them; validate the sum against the total VAT line, as in Extracting Tables from PDFs.
Related
- Processing Email Attachments Automatically — the intake that fills the incoming folder
- Extracting PDF Data into pandas — extraction techniques for text and tables
- Quarantine Invalid Rows in a Pipeline — the quarantine pattern applied to rows
- Fix PDF Numbers Parsed as Strings in pandas — locale-aware number parsing in depth