Process received supplier invoices end to end: register intake, extract fields, validate totals and tax math, match against purchase orders or expected charges, catch exact and suspect duplicates, route exceptions by reason, and produce an approved-for-payment list. Use when invoices arrive for payment and need checking before any money moves.
name: invoice_processing_automation description: "Process received supplier invoices end to end: register intake, extract fields, validate totals and tax math, match against purchase orders or expected charges, catch exact and suspect duplicates, route exceptions by reason, and produce an approved-for-payment list. Use when invoices arrive for payment and need checking before any money moves." metadata: { "includeInPrompt": true }
Invoice Processing Automation
Purpose
Run the accounts payable intake pipeline for invoices received from suppliers: register each invoice on arrival, extract its fields, validate the math, match it against a purchase order or an expected charge, stop duplicates before payment, route every failure to an exception queue with a reason, and hand over a clean approved-for-payment list. This skill owns validation, matching, deduplication, and approval routing for money going out.
Scope:
- invoice-runner is the opposite direction: issuing invoices for work delivered and chasing payment on them (accounts receivable). This skill never issues an invoice and never chases a payer; it checks invoices other parties issued before they get paid.
- pdf-processing-automation is single-file and small-batch PDF edits and extraction; its own description limits it to jobs that fit in a script, not a processing pipeline. This skill is the pipeline that sits downstream of extraction: a PDF tool may pull the fields out of one file, but registering, validating, matching, and routing a stream of invoices is the work here.
- statement-bookkeeping turns bank and card statements into a categorized ledger, a reconciliation, and a profit and loss report. This skill works before payment, on the invoice itself; a paid invoice later appears as a statement line for that skill, not here.
- tax-ready-document-filing extracts vendor, invoice number, date, and amount from invoices and receipts only to build a tax archive manifest and file copies into a year, quarter, and category tree. This skill extracts a wider field set (lines, tax, purchase order reference, terms) to decide whether an invoice may be paid, and its output is a payment list, not an archive.
- This skill does not release payments, change supplier bank details, or give tax advice. It produces the approved-for-payment list and the exception list; payment release and bank detail changes follow the separation rules in the Operating Rules section.
Workflow
- Register every invoice on arrival.
- Pull from every intake channel into one log: the payables inbox, supplier portal downloads, scanned paper, and any upload folder. An invoice that never enters the log is an invoice nobody checks, and it still gets paid late or twice.
- Assign a sequential intake ID on arrival and record: received date, channel, supplier name exactly as printed, file name, and the original file location. Keep the original file unchanged and work from the extracted fields and a copy.
- Never treat an email body, a screenshot, or a quoted total in a message as an invoice. If the invoice document itself is missing, log the request as missing-document and chase the document; do not start validation on a summary.
- The same invoice arriving through two channels gets two intake rows at this step. That is expected. Deduplication in step 5, not intake guesswork, decides that both rows are one obligation.
- Extract the fields the checks need.
- Extract, per invoice: supplier name, invoice number exactly as printed, invoice date, due date, currency, purchase order number if printed, each line (description, quantity, unit price, line amount), subtotal, tax rate or rates, tax amount, any shipping or handling shown separately from the subtotal, total, payment terms, and the payment details as printed.
- Use native text extraction for born-digital PDFs and OCR for scans. Record a confidence note for any field read from a scan; a low-confidence amount, date, or invoice number goes to the exception queue for a manual read against the original before any check runs on it. Never guess a digit.
- Preserve the invoice number as a string, character for character, including leading zeros and hyphens. INV-001 and INV-1 are different strings until a person confirms a supplier prints both forms for one series.
- If a field the invoice should carry is absent, record it as absent. A blank due date is filled from the payment terms and the invoice date only when the terms are printed on the invoice or on the matched purchase order; otherwise the invoice is held as missing_field in step 3.
- Validate required fields and dates.
- Required for every check that follows: supplier, invoice number, invoice date, currency, at least one line, subtotal, tax amount, and total. Any one missing holds the invoice with reason missing_field.
- Check the supplier against the supplier register. An invoice from a supplier with no register entry is held, and the register entry is created through the normal supplier setup path, never as a side effect of approving the invoice in the same pass.
- Check dates in order: invoice date is not after the received date, due date is not before the invoice date, and the invoice date falls in an open accounting period. A date outside these bounds is routed as an exception with the printed date quoted; never silently correct a printed date to make the invoice fit a period.
- Check the currency matches the purchase order currency or the expected charge currency. A currency mismatch is held, not converted: conversion at payment time is a payment decision, and matching in two currencies hides real variances.
- Check the math, line by line, before matching anything.
- Subtotal must equal the sum of the line amounts within 0.01.
- Tax must equal the subtotal times the stated tax rate, rounded to the cent, within 0.01. Where an invoice carries two tax rates, split the lines by rate, check each group on its own, and check that the group taxes sum to the stated tax amount.
- Where quantity and unit price were extracted, each line amount must equal quantity times unit price within 0.01 per line.
- Total must equal subtotal plus tax plus any shipping or handling shown outside the subtotal, within 0.01.
- Record every mismatch as expected value versus stated value, with the reason code from step 7 (subtotal_mismatch, tax_mismatch, or total_mismatch). Never edit a stated figure to make an invoice pass. The stated total is the amount the supplier expects to receive, so a corrected copy or a credit note has to come from the supplier; the checker's job is to hold and report, not to rewrite.
- Catch duplicates before matching or approval.
- Exact duplicate key: normalized supplier (lowercase, extra spaces collapsed), invoice number (uppercase, trimmed), and total. Run the key against the current batch, every earlier batch in the period, and the paid history. Any second hit is held with reason duplicate. Paying the same invoice twice is a mechanical failure, not a judgment call, and no approval overrides it.
- Suspect duplicate key: same normalized supplier, same invoice date, and same total, under a different invoice number. Hold with reason duplicate_suspect. The common causes are a supplier resending with a corrected number, the same invoice filed from two channels, and a second invoice that happens to share a round total. Only the first two may be released, and only after the extra copy is confirmed as a resend in writing from the supplier or matched to a credit note.
- Same supplier and same invoice number at a different total is also held, as duplicate_suspect in the report with a note: it is either an amended invoice or a keying error, and neither copy is payable until the supplier confirms which total stands and cancels the other.
- Deduplication runs twice: once here, during validation, and again in step 8 when the payment list is built. An invoice approved last week and paid must still block its twin arriving this week, which only the second run against the paid history can do.
- Match against the purchase order or the expected charge.
- Purchase order present: do a three-way match across the purchase order, the goods or service receipt, and the invoice. Supplier must match the purchase order supplier (a mismatch is held as po_vendor_mismatch), the purchase order number must exist (held as po_not_found if it does not), quantities invoiced must not exceed quantities received, and unit prices must match the purchase order prices.
- Amount tolerance for the purchase order match: the invoice total passes if it differs from the purchase order total by no more than the larger of 1.00 and 1 percent of the purchase order total. Beyond that, hold with reason po_mismatch and route it to the purchase order raiser, who either amends the purchase order or confirms the variance in the match record. Ask the raiser before querying the supplier; most overages are an unamended order, not a supplier error.
- Split price and quantity variances when routing: a price variance goes to the buying role that set the order price, and a quantity variance goes to the receiving role that recorded delivery. One combined query that mixes both stalls, because neither side owns the whole answer.
- Partial delivery: match the invoice against the received quantity only, pay the matched part when the rest of the checks pass, and leave the purchase order open for the balance. Never close a purchase order on a partial invoice, and record the invoiced-to-date total on the order so a later invoice cannot push the cumulative total past the order total unnoticed.
- No purchase order: match against the expected charge list instead: contracts, subscriptions, utilities, rent, and standing services with an expected amount and period. Record which expected charge the invoice matched and the variance under the same tolerance as the purchase order match. An invoice with no purchase order and no matching expected charge is held and routed for approval under step 7; a small amount changes who approves it, never whether it is recorded and checked.
- Route exceptions and collect approvals.
- Every held invoice lands in one exception queue with: intake ID, supplier, invoice number, total, the reason code, expected versus stated values where the reason is a math or match failure, the assigned queue, and the date it was held. Reason codes used by this skill: missing_field, subtotal_mismatch, tax_mismatch, total_mismatch, duplicate, duplicate_suspect, po_not_found, po_vendor_mismatch, po_mismatch.
- Assign each code a fixed queue so routing is not reinvented per invoice: math mismatches and missing fields go back to the supplier as one query listing every discrepancy on that invoice at once; duplicates go to the payables queue for the resend confirmation; purchase order codes go to the order raiser or the buying or receiving role per step 6; an unregistered supplier goes to supplier setup.
- Set approval thresholds as local policy before the first run and write them into the run notes: the amount up to which one approver suffices, the amount above which two approvers are required, and any category that always needs two (first invoice from a new supplier, any invoice tied to a bank detail change). The thresholds decide who approves; they never decide whether the checks run.
- Work the queue before every payment run, and sweep it again for any held invoice whose due date falls within the payment lead time. An exception left sitting until after the due date converts a supplier error into a late payment owned by the pipeline.
- Keep approval on the record: approver identity, date, and the list or exception item approved. An approval given in a chat message with no link to the intake ID does not count; re-ask for it against the item.
- Build the approved-for-payment list.
- Include only invoices with verdict APPROVED from the check in the Operating Rules section: no held item, no partially edited item, and no item approved verbally outside the queue.
- Group the list by currency. For each currency, state the invoice count and the sum, and confirm the sum equals the approved_total printed by the check for that run. A list whose total disagrees with the check output is rebuilt, not hand-adjusted.
- Re-run deduplication across the finished list plus the paid history immediately before handing the list over (step 5, second run). A duplicate found here stops the whole list until it is removed and the totals are recomputed.
- Order the list by due date. Pay on the due date by default; pay early only where printed terms offer an early payment discount and the discount value beats the cost of paying early under local policy, with the discount terms quoted on the list next to that invoice.
- Record the outcome and reconcile after payment.
- Enter the approved list into the accounting ledger with, per invoice: intake ID, supplier, invoice number, purchase order or expected charge reference, total, currency, and approval record. The ledger entry is created from the list, never retyped from the invoice, so a figure cannot drift between approval and entry.
- After payment, match each payment confirmation back to its intake ID and mark the invoice paid with the payment date and reference. Paid invoices leave the open pool and stay in the duplicate history permanently.
- Close each period with three counts that must agree or be explained in the run notes: invoices registered, invoices approved plus held plus rejected, and payments made against the period's approved lists. A payment with no matching approved invoice, or an approved invoice with no payment and no hold note, is an open item for the next run, listed by intake ID.
Output Contract
Deliver these items, in this order:
- Intake log: one row per registered invoice with intake ID, received date, channel, supplier as printed, invoice number, and original file location. Every invoice that entered the pipeline appears here, including the ones later held or rejected.
- Check output: the full output of invoice_check.py for the run. On the worked fixture in the Operating Rules section the headline numbers are: invoices 9, approved 3, held 6, approved_total 1404.54, held_total 3849.00, and one each of reason_duplicate, reason_duplicate_suspect, reason_missing_field, reason_po_mismatch, reason_tax_mismatch, and reason_total_mismatch.
- Approved-for-payment list: one row per approved invoice with intake ID, supplier, invoice number, due date, purchase order or expected charge reference, and total, grouped by currency with a count and a sum per currency. Each currency sum equals the approved_total for that currency from the check output.
- Exception list: one row per held invoice with intake ID, supplier, invoice number, total, reason code, expected versus stated values for math and match failures, the assigned queue, the next action, and the due date so late-payment risk is visible.
- Match record: one row per matched invoice with the purchase order number or expected charge ID, the order or expected total, the invoice total, the variance, and the tolerance applied (the larger of 1.00 and 1 percent of the order total).
- Limits: checks that could not be run for this batch and why (for example lines that could not be read from a scan, a purchase order file that was unavailable, or a paid history that did not cover an earlier period), plus any invoice released from a hold during the run and the written confirmation that released it.
Operating Rules
- Deduplicate before payment, twice. The exact key (normalized supplier, invoice number, total) is checked during validation and again against the paid history when the payment list is built. A second payment of the same invoice is mechanically wrong; no approval, urgency, or supplier request overrides a duplicate hold. Only a written supplier confirmation that the second document is a corrected replacement, plus cancellation of the first, releases it.
- Stated figures are never edited to pass. Subtotal, tax, and total are checked as printed, with a tolerance of 0.01 per check. A mismatch is held and reported as expected versus stated; the fix is a corrected invoice or a credit note from the supplier.
- No invoice reaches the payment list with an open reason. One failed check holds the whole invoice, even when every other check passes and the failure looks small. Small math errors are how wrong totals get paid at scale.
- Payment details printed on an invoice are never acted on alone. A new or changed bank account, account name, or payment link is verified through the contact already on the supplier register, by a channel that does not come from the invoice under review, before that invoice is approved. An invoice carrying unverified new details is held regardless of its other checks.
- Entry, approval, and payment release stay separate. The role that registers and checks an invoice is not its sole approver, and the approver does not release the payment. Any batch where one role performed two of the three steps is flagged in the run notes.
- Every number in the output comes from an invoice, a purchase order, an expected charge record, or the check output. Expected values are computed and shown next to stated values; no total is quoted from memory of an earlier run.
- An unregistered supplier is always held. Supplier setup finishes first, with its own checks, and the invoice re-enters validation afterward. Approving the invoice never creates the supplier implicitly.
- Use the checker below for the mechanical checks. Save it as invoice_check.py, run it on the extracted invoices and the purchase order list before building any list, and put its full output in the deliverable as item 2 of the Output Contract. The script exits with code 1 when any invoice is held and 0 when all pass, so a payment run can gate on the exit code. Report numbers from its output, not from a manual count.
Invoice checker (invoice_check.py)
Run: python3 invoice_check.py --make-fixture reproduces the worked example. Run it on real extracted data with python3 invoice_check.py invoices.json pos.json, where invoices.json is a list of extracted invoice objects and pos.json is a list of purchase order objects in the shapes the script header describes. The script uses only the Python standard library (verified on Python 3.12.3) and reads amounts as Decimal, so cent rounding is exact.
Expected output for the fixture run (exit code 1, because 6 invoices are held):
text
invoices: 9
approved: 3
held: 6
approved_total: 1404.54
held_total: 3849.00
reason_duplicate: 1
reason_duplicate_suspect: 1
reason_missing_field: 1
reason_po_mismatch: 1
reason_tax_mismatch: 1
reason_total_mismatch: 1
APPROVED INV-1001 Acme Supplies 1080.00
HOLD INV-2001 Beta Parts 275.00 [tax_mismatch]
HOLD INV-1001 Acme Supplies 1080.00 [duplicate]
HOLD INV-5001 Epsilon Services 550.00 [total_mismatch]
HOLD INV-3002 Gamma Logistics 648.00 [po_mismatch]
HOLD - Delta Office 216.00 [missing_field]
APPROVED INV-4001 Delta Office 216.54
HOLD INV-1009 Acme Supplies 1080.00 [duplicate_suspect]
APPROVED INV-2002 Beta Parts 108.00
The fixture is built to trip each check once: INV-2001 states tax of 25.00 on a 250.00 subtotal at 0.08 (expected tax 20.00), the second INV-1001 is an exact duplicate of the first, INV-5001 states a total of 550.00 against a subtotal plus tax of 540.00, INV-3002 totals 648.00 against purchase order PO-9002 at 540.00 (past the 5.40 tolerance), the Delta Office invoice at 216.00 carries no invoice number, and INV-1009 repeats the supplier, date, and total of INV-1001 under a different number. INV-4001 passes its purchase order match inside tolerance: 216.54 against PO-9003 at 216.00 is a variance of 0.54, within the 2.16 tolerance. INV-2002 passes with no purchase order, which is correct at the check stage: a missing purchase order is routed through the expected charge match and approval in Workflow steps 6 and 7, not failed by the math and duplicate checks.
invoice_check.py (Python 3.12.3, standard library only). Save this code as invoice_check.py:
python
#!/usr/bin/env python3
"""Validate received supplier invoices, match them to purchase orders,
and catch duplicates before payment. Python 3.12.3, stdlib only.
Usage:
python3 invoice_check.py --make-fixture
python3 invoice_check.py invoices.json pos.json
Input JSON shapes:
invoices: [{"vendor": str, "invoice_number": str, "date": "YYYY-MM-DD",
"currency": str, "po_number": str or null,
"lines": [amount, ...], "subtotal": amount,
"tax_rate": amount, "tax": amount, "total": amount}, ...]
pos: [{"po_number": str, "vendor": str, "total": amount}, ...]
Amounts may be numbers or strings; they are read as Decimal.
Checks per invoice, in order: missing required fields, subtotal equals
the sum of lines, tax equals subtotal times tax_rate, total equals
subtotal plus tax, exact duplicate, suspect duplicate, and purchase
order match. Money tolerance for a single check is 0.01. A purchase
order amount passes if it is within the larger of 1.00 and 1 percent
of the purchase order total. An invoice with any reason is held.
"""
import json
import sys
from decimal import Decimal, ROUND_HALF_UP
CENT = Decimal("0.01")
TOL = Decimal("0.01")
def money(value):
return Decimal(str(value)).quantize(CENT, rounding=ROUND_HALF_UP)
def norm_vendor(name):
return " ".join(str(name or "").strip().lower().split())
def norm_number(number):
return str(number or "").strip().upper()
def fixture():
pos = [
{"po_number": "PO-9001", "vendor": "Acme Supplies", "total": "1080.00"},
{"po_number": "PO-9002", "vendor": "Gamma Logistics", "total": "540.00"},
{"po_number": "PO-9003", "vendor": "Delta Office", "total": "216.00"},
]
invoices = [
{"vendor": "Acme Supplies", "invoice_number": "INV-1001",
"date": "2026-09-02", "currency": "USD", "po_number": "PO-9001",
"lines": ["600.00", "400.00"], "subtotal": "1000.00",
"tax_rate": "0.08", "tax": "80.00", "total": "1080.00"},
{"vendor": "Beta Parts", "invoice_number": "INV-2001",
"date": "2026-09-03", "currency": "USD", "po_number": None,
"lines": ["200.00", "50.00"], "subtotal": "250.00",
"tax_rate": "0.08", "tax": "25.00", "total": "275.00"},
{"vendor": "Acme Supplies", "invoice_number": "INV-1001",
"date": "2026-09-02", "currency": "USD", "po_number": "PO-9001",
"lines": ["600.00", "400.00"], "subtotal": "1000.00",
"tax_rate": "0.08", "tax": "80.00", "total": "1080.00"},
{"vendor": "Epsilon Services", "invoice_number": "INV-5001",
"date": "2026-09-05", "currency": "USD", "po_number": None,
"lines": ["500.00"], "subtotal": "500.00",
"tax_rate": "0.08", "tax": "40.00", "total": "550.00"},
{"vendor": "Gamma Logistics", "invoice_number": "INV-3002",
"date": "2026-09-06", "currency": "USD", "po_number": "PO-9002",
"lines": ["600.00"], "subtotal": "600.00",
"tax_rate": "0.08", "tax": "48.00", "total": "648.00"},
{"vendor": "Delta Office", "invoice_number": "",
"date": "2026-09-07", "currency": "USD", "po_number": "PO-9003",
"lines": ["200.00"], "subtotal": "200.00",
"tax_rate": "0.08", "tax": "16.00", "total": "216.00"},
{"vendor": "Delta Office", "invoice_number": "INV-4001",
"date": "2026-09-08", "currency": "USD", "po_number": "PO-9003",
"lines": ["200.50"], "subtotal": "200.50",
"tax_rate": "0.08", "tax": "16.04", "total": "216.54"},
{"vendor": "Acme Supplies", "invoice_number": "INV-1009",
"date": "2026-09-02", "currency": "USD", "po_number": None,
"lines": ["1000.00"], "subtotal": "1000.00",
"tax_rate": "0.08", "tax": "80.00", "total": "1080.00"},
{"vendor": "Beta Parts", "invoice_number": "INV-2002",
"date": "2026-09-09", "currency": "USD", "po_number": None,
"lines": ["100.00"], "subtotal": "100.00",
"tax_rate": "0.08", "tax": "8.00", "total": "108.00"},
]
return invoices, pos
def check(invoices, pos):
po_map = {p["po_number"]: p for p in pos}
seen_exact = {}
seen_suspect = {}
results = []
for idx, inv in enumerate(invoices):
reasons = []
vendor = inv.get("vendor") or ""
number = inv.get("invoice_number") or ""
total = money(inv.get("total", "0"))
if not str(vendor).strip() or not str(number).strip() \
or not str(inv.get("date") or "").strip() \
or not str(inv.get("currency") or "").strip():
reasons.append("missing_field")
lines_total = sum((money(x) for x in inv.get("lines", [])),
Decimal("0.00"))
subtotal = money(inv.get("subtotal", "0"))
if abs(lines_total - subtotal) > TOL:
reasons.append("subtotal_mismatch")
tax_rate = Decimal(str(inv.get("tax_rate", "0")))
expected_tax = (subtotal * tax_rate).quantize(
CENT, rounding=ROUND_HALF_UP)
if abs(expected_tax - money(inv.get("tax", "0"))) > TOL:
reasons.append("tax_mismatch")
expected_total = subtotal + money(inv.get("tax", "0"))
if abs(expected_total - total) > TOL:
reasons.append("total_mismatch")
exact_key = (norm_vendor(vendor), norm_number(number), str(total))
if exact_key in seen_exact and str(number).strip():
reasons.append("duplicate")
else:
if str(number).strip():
seen_exact[exact_key] = idx
suspect_key = (norm_vendor(vendor), str(inv.get("date") or ""),
str(total))
if suspect_key in seen_suspect \
and seen_suspect[suspect_key] != norm_number(number) \
and "duplicate" not in reasons:
reasons.append("duplicate_suspect")
else:
seen_suspect.setdefault(suspect_key, norm_number(number))
po_number = inv.get("po_number")
if po_number:
po = po_map.get(po_number)
if po is None:
reasons.append("po_not_found")
elif norm_vendor(po.get("vendor")) != norm_vendor(vendor):
reasons.append("po_vendor_mismatch")
else:
po_total = money(po.get("total", "0"))
tolerance = max(Decimal("1.00"),
(po_total * Decimal("0.01")).quantize(
CENT, rounding=ROUND_HALF_UP))
if abs(total - po_total) > tolerance:
reasons.append("po_mismatch")
verdict = "HOLD" if reasons else "APPROVED"
results.append((verdict, number or "-", vendor, total, reasons))
return results
def report(results):
approved = [r for r in results if r[0] == "APPROVED"]
held = [r for r in results if r[0] == "HOLD"]
print(f"invoices: {len(results)}")
print(f"approved: {len(approved)}")
print(f"held: {len(held)}")
print(f"approved_total: {sum((r[3] for r in approved), Decimal('0.00')):.2f}")
print(f"held_total: {sum((r[3] for r in held), Decimal('0.00')):.2f}")
counts = {}
for _, _, _, _, reasons in results:
for reason in reasons:
counts[reason] = counts.get(reason, 0) + 1
for reason in sorted(counts):
print(f"reason_{reason}: {counts[reason]}")
for verdict, number, vendor, total, reasons in results:
suffix = f" [{', '.join(reasons)}]" if reasons else ""
print(f"{verdict} {number} {vendor} {total:.2f}{suffix}")
return 1 if held else 0
def main():
args = sys.argv[1:]
if args == ["--make-fixture"]:
invoices, pos = fixture()
return report(check(invoices, pos))
if len(args) != 2:
print("usage: invoice_check.py --make-fixture | invoices.json pos.json")
return 2
with open(args[0], encoding="utf-8") as fh:
invoices = json.load(fh)
with open(args[1], encoding="utf-8") as fh:
pos = json.load(fh)
return report(check(invoices, pos))
if __name__ == "__main__":
raise SystemExit(main())
No ratings yet — be the first to rate it.