A supplier invoice doesn’t look expensive. It’s a PDF in your email or paper taped to a delivery. But each one needs the same treatment: find the invoice number, date, supplier, amounts and tax, type them into a sheet or accounting app, file the original. Thirty invoices a month is real hours; three hundred is a working week.

This guide shows what that typing actually costs, with numbers you can check against your own routine, then builds a Python script that uses Gemini’s vision API to do the reading — invoice PDFs and photos both — with a validation step that catches wrong reads instead of trusting them. AI cost for a typical month: under $1. Build time: about an hour.

What a manual invoice routine actually costs

Before building anything, measure the current process. Take ten invoices, and for each one time:

Finding it, reading it, typing the fields, filing the original: for most people this lands between 2 and 5 minutes per invoice once finding and filing are included. Typed wrong at a rate of a few percent, which means time spent later hunting a discrepancy.

The table uses a small trading business: 150 invoices/month, 3 minutes each, staff at Rp 30,000/hour — adjust to your local wage; the method matters more than the number.

ManualWith the script below
Time per invoice3 min~20 sec (drop in folder + check flagged ones)
Monthly hours7.5 h~1 h
Staff cost of that timeRp 225,000Rp 30,000
AI cost—~$3 (Rp 50,000)
Wrong numbersfound at reconciliationflagged at entry time

The saving isn’t just hours: mistakes surface the day the invoice enters, while the paper is still on the desk, not weeks later when the numbers stop adding up. And these numbers are illustrative — time yourself before you believe them, or quote a client.

What you need

ItemCostNotes
Python 3.10+$0Laptop, mini PC, or a small VPS
Gemini API key$0 to startaistudio.google.com → “Get API key”
google-genai, pydantic, pypdf, pdf2image + poppler$0pip install (poppler is a system package)
pillow, pillow-heif$0For phone photos, including iPhone HEIC

Pricing as of October 2026 — check ai.google.dev/pricing before you rely on it, because Google changes models and prices often:

  • Gemini 2.5 Flash (paid tier): $0.30 per 1M input tokens, $2.50 per 1M output tokens
  • Free tier: rate-limited and fine for testing with made-up invoices. Google’s terms allow free-tier content to be used to improve its products, so don’t put real supplier invoices through it. Paid tier doesn’t work that way.

How the cost works out

Invoices are bigger than receipts, so the maths differs a little:

  • A one-page invoice photo resized to 1536 px: roughly 1,000–2,000 image tokens
  • A one-page PDF sent directly: roughly 300–800 tokens
  • Instructions and schema: about 400 tokens
  • JSON output: about 300–500 tokens

Per invoice on 2.5 Flash:

  • Input: ~2,000 tokens × $0.30/1M = $0.0006
  • Output: ~450 tokens × $2.50/1M = $0.0011
  • Total: about $0.002, call it $0.003 with re-runs and PDF multi-page documents

150 invoices/month ≈ $0.45. Even at 1,000 invoices a month you’re under $5.

Step 1: Project setup

mkdir invoices && cd invoices
python3 -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install google-genai pydantic pypdf pillow pillow-heif

mkdir inbox processed review
export GEMINI_API_KEY="paste-your-key-here"   # Windows PowerShell: $env:GEMINI_API_KEY="..."

Poppler is needed by pdf2image — install once:

  • Ubuntu/Debian/VPS: sudo apt install poppler-utils
  • macOS: brew install poppler
  • Windows: download the poppler release, add its bin folder to PATH

Folder layout:

invoices/
├── inbox/       # drop PDFs and photos here
├── processed/   # passed all checks
├── review/      # flagged, needs a human
├── invoices.csv # one row per invoice
└── process.py

Step 2: The script

Save as process.py. It handles both PDF pages and photos, extracts structured fields, checks the arithmetic, and never trusts the AI silently.

import csv, hashlib, os, shutil, sys
from io import BytesIO
from pathlib import Path

from google import genai
from google.genai import types
from PIL import Image, ImageOps
from pydantic import BaseModel

try:
    from pillow_heif import register_heif_opener   # iPhone photos
    register_heif_opener()
except ImportError:
    pass

MODEL = os.environ.get("INVOICE_MODEL", "gemini-2.5-flash")
INBOX, DONE, REVIEW = Path("inbox"), Path("processed"), Path("review")

class LineItem(BaseModel):
    description: str
    qty: float
    unit_price: float
    total: float

class Invoice(BaseModel):
    supplier: str
    invoice_number: str
    date: str            # YYYY-MM-DD or "unknown"
    due_date: str        # YYYY-MM-DD or "unknown"
    currency: str
    subtotal: float
    tax: float           # PPN / VAT / GST; 0 if not shown
    other_charges: float # shipping etc.; negative for discounts
    total: float
    items: list[LineItem]
    legible: bool

PROMPT = """You are extracting data from a supplier invoice (photo or PDF page).
Rules:
- Copy every number exactly as printed. "Rp 1.250.000" -> 1250000, "$1,250.00" -> 1250.0.
- Indonesian invoices use '.' as thousands separator; US ones use ','.
- Missing fields stay empty strings or 0. Never guess.
- If key fields are unreadable, set legible=false.
"""

client = genai.Client()   # reads GEMINI_API_KEY

def pdf_pages(path: Path) -> list[bytes]:
    """Render PDF pages to JPEG bytes so one API call covers the whole document."""
    from pdf2image import convert_from_bytes
    pages = convert_from_bytes(path.read_bytes(), dpi=150)
    out = []
    for page in pages:
        page.thumbnail((1536, 1536))
        buf = BytesIO()
        page.save(buf, format="JPEG", quality=85)
        out.append(buf.getvalue())
    return out

def photo_bytes(path: Path) -> list[bytes]:
    img = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
    img.thumbnail((1536, 1536))
    buf = BytesIO()
    img.save(buf, format="JPEG", quality=85)
    return [buf.getvalue()]

def extract(path: Path) -> Invoice:
    if path.suffix.lower() == ".pdf":
        pages = pdf_pages(path)
    else:
        pages = photo_bytes(path)
    parts = [types.Part.from_bytes(data=p, mime_type="image/jpeg") for p in pages]
    resp = client.models.generate_content(
        model=MODEL,
        contents=[*parts, PROMPT],
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
            response_schema=Invoice,
            temperature=0,
            thinking_config=types.ThinkingConfig(thinking_budget=0),
        ),
    )
    return resp.parsed

def problems(inv: Invoice) -> list[str]:
    issues = []
    if not inv.legible:
        issues.append("model says not legible")
    if inv.date == "unknown" or not inv.invoice_number:
        issues.append("missing date or invoice number")
    tol = max(1.0, inv.total * 0.01)   # 1% or 1 unit, absorbs rounding
    items_sum = sum(i.total for i in inv.items)
    if inv.items:
        exclusive_ok = abs(items_sum + inv.tax + inv.other_charges - inv.total) <= tol
        inclusive_ok = abs(items_sum - inv.total) <= tol
        if not (exclusive_ok or inclusive_ok):
            issues.append(f"items {items_sum:.0f} + tax/charges != total {inv.total:.0f}")
    for i in inv.items:
        if abs(i.qty * i.unit_price - i.total) > max(1.0, i.total * 0.01):
            issues.append(f"line '{i.description[:25]}' qty x price != total")
    return issues

def append_csv(row: dict):
    new = not Path("invoices.csv").exists()
    with open("invoices.csv", "a", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=row.keys())
        if new:
            w.writeheader()
        w.writerow(row)

def main():
    for path in sorted(INBOX.iterdir()):
        if path.suffix.lower() not in {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".heic"}:
            continue
        digest = hashlib.sha256(path.read_bytes()).hexdigest()[:16]
        try:
            inv = extract(path)
        except Exception as e:
            print(f"ERROR {path.name}: {e}", file=sys.stderr)
            continue                       # stays in inbox, retried next run
        if inv is None:
            print(f"ERROR {path.name}: unparseable response", file=sys.stderr)
            continue
        issues = problems(inv)
        status = "review" if issues else "ok"
        append_csv({
            "id": digest, "file": path.name, "status": status,
            "issues": "; ".join(issues), "supplier": inv.supplier,
            "invoice_number": inv.invoice_number, "date": inv.date,
            "due_date": inv.due_date, "currency": inv.currency,
            "subtotal": inv.subtotal, "tax": inv.tax,
            "other_charges": inv.other_charges, "total": inv.total,
        })
        shutil.move(path, (REVIEW if issues else DONE) / f"{digest}_{path.name}")
        print(f"{status:6} {inv.date} {inv.supplier[:22]:22} {inv.invoice_number[:14]:14} {inv.total:>13,.0f}")

if __name__ == "__main__":
    main()

Drop five real invoices in inbox/:

python process.py

You get one CSV row per invoice, and anything whose maths doesn’t check out lands in review/ with the reason in the issues column.

Step 3: Why the checks are the product

The model doesn’t know when it’s wrong — it returns confident JSON either way. Each check catches a failure you will actually meet:

  • Thousands-separator confusion. Rp 1.250.000 read as 1.25 breaks the line-sum check immediately.
  • Missed pages. Multi-page PDFs where only page one gets read: totals stop matching, so it’s flagged.
  • Skipped discount rows. The arithmetic check notices negative amounts.
  • Faded scans. The model’s own legible=false flags the worst before they poison the books.

Rule: always trust the printed total over the line items. For payables, the total and due date are what matter. Line items are for checking, not for the books.

Spend the first week cross-checking every ok row by hand. If a few hundred invoices come through clean, start only reading the review pile. If not, look for the pattern — usually one supplier’s weird layout.

Step 4: Make it hands-off

Two small additions turn this into a pipeline for the invoices that arrive by email:

  1. Auto-download attachments. Most email clients filter by sender and save to a folder; or a 20-line imaplib script saves PDF attachments from known suppliers into inbox/.
  2. Run on a schedule. Cron, every evening:
crontab -e
# add:
0 20 * * * cd /home/you/invoices && GEMINI_API_KEY=xxx .venv/bin/python process.py >> run.log 2>&1

On Windows, use Task Scheduler with .venv\Scripts\python.exe process.py and the invoices folder as “Start in”.

Honest cost breakdown

Scenario: 150 invoices/month.

ItemMonthly
Gemini 2.5 Flash, 150 × ~$0.002 + re-runs~$0.50
Google Workspace / email / computer$0 (existing)
VPS if your laptop isn’t always on~$5 (optional)
Your time: dropping files + reviewing flags~1 h
Total cash costunder $1 (or ~$6 with VPS)

Against the manual baseline: 7.5 hours of staff time becomes about 1 hour. At almost any local wage the tool pays for itself on day one of the month — but these are model numbers; measure your own.

How to Sell This as a Service

The buyer doesn’t care about AI. They care that the pile disappears. Sell the pile, not the pipeline.

Who to approach. Businesses with 50+ supplier invoices a month and no accounting software: distributors, building-supply shops, caterers, small manufacturers. Second target: freelance bookkeepers and tax consultants (konsultan pajak) — one relationship is worth ten shops.

The offer:

  1. Setup (one-off): install the folder, script and CSV on their machine, on their own Gemini key with billing on — API bill and data stay theirs. Tune the checks to their suppliers’ formats, train one staff member. Two to four hours of your work.
  2. Monthly (optional): check the review/ pile, reconcile the CSV against the bank statement, deliver a payables summary with due dates — a bookkeeping-assistant deliverable in a fraction of the usual hours.

Pricing. Start from what the manual routine costs them, not your API bill. If staff spend 8 hours a month typing invoices, price setup and monthly fee well under that staff cost while still worth your time. For an Indonesian SME, a test zone is around Rp 750,000–2,000,000 one-off plus Rp 300,000–800,000/month for reconciliation — validated against local wages, then adjusted. There’s no market rate; the first client teaches you the real one.

The demo that sells. Take their worst invoice — crumpled, slanted, bad photocopy — run it live, show the CSV row appearing with the numbers correct. Then show a flagged one and explain that the tool refuses to guess. The refusal is what makes a bookkeeper trust it.

Don’t promise. Not “fully automated accounts payable”. Promise “your invoices typed and checked by tomorrow morning, and the weird ones get flagged for a human”. You can deliver that every month.

Reality check: what does NOT work

  • Skipping human review. Most invoices read cleanly; “most” isn’t a bookkeeping standard. The review/ folder is the product.
  • Real client documents on the free tier. Google’s terms allow free-tier content to be used to improve its products, possibly by human reviewers. Supplier invoices are commercially sensitive — turn on billing; it costs less than a coffee a month.
  • Treating this as accounting. Clean data entry isn’t bookkeeping: deductibility, tax treatment and filings still belong to an accountant. Keep originals in processed/ as evidence, backed up off the machine.
  • Assuming duplicates are caught. A supplier re-sending the same PDF under a new filename gets through a hash check. Monthly, sort by supplier + invoice_number and look for repeats.
  • Bad photos. Shadows, curled paper and flash glare cause most “the AI is wrong” complaints. Flatten, daylight, fill the frame.
  • Model churn. Google retires model versions on a schedule. When yours gets a shutdown date, change INVOICE_MODEL, re-run ten known invoices, compare before switching a client over.

Final checklist

  • Script tested on 10 of your own invoices, every row checked by hand
  • thinking_budget=0 set, PDFs rendering correctly
  • Billing enabled before any real financial document goes through
  • review/ pile checked weekly by a named person
  • Originals in processed/ backed up off the machine
  • Monthly duplicate scan on supplier + invoice number

Run it on your own invoice pile for a month. If the review stack stays small and the CSV reconciles, you have a time-saver and a demo worth walking into a business with.