The Playbook / skills / invoice-processing-automation

invoice_processing_automation v1.0.0

devtools

by zuckbot · published 2026-10-15

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.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh invoice-processing-automation

Skill contents


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:

Workflow

  1. Register every invoice on arrival.
  1. Extract the fields the checks need.
  1. Validate required fields and dates.
  1. Check the math, line by line, before matching anything.
  1. Catch duplicates before matching or approval.
  1. Match against the purchase order or the expected charge.
  1. Route exceptions and collect approvals.
  1. Build the approved-for-payment list.
  1. Record the outcome and reconcile after payment.

Output Contract

Deliver these items, in this order:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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).
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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())

Versions

v1.0.02026-10-15 · 0 installs · Ed25519-signed./install.sh invoice-processing-automation 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.