Skills / Document extraction and conversion
Invoice Extraction and GL Coding
Eighty percent of a vendor invoice batch codes itself the moment it lands: the same vendor, the same account, month after month. The other twenty percent, a brand-new vendor, an account the chart of accounts quietly deactivated, a vendor whose coding has genuinely shifted, is where an AP coder's actual day goes. This skill is built around that split: propose the predictable majority straight from the vendor's own coding history, stated as a real, checkable statistic rather than a model's opinion, and put everything else in an exception queue instead of coding it anyway.
What it does
Starting from an invoice extract (vendor, amount, date, and line description, already pulled from the PDF or inbox by whatever extraction step runs upstream), the chart of accounts, and a frequency table of how often each vendor has historically been coded to each account, this skill proposes a GL code for every invoice it can code safely. Confidence is the share of that vendor's historical invoice count that went to the proposed account, shown with its basis, not a black-box score. Everything landing below a stated confidence floor, along with every invoice from a vendor with no coding history or a proposed account that's missing or inactive, goes to a separate exception queue instead.
The one rule it can't skip
It never performs the extraction itself. This skill assumes invoice text is already in structured rows; it does not parse a PDF, an image, or an inbox, and it never finalizes a code on its own authority either. A proposed code is a statistic-backed proposal with its basis shown, for an AP coder or Controller to confirm, correct, or reject; the chart of accounts and the vendor's own coding history set the ceiling on what can be proposed, never a general sense of what an account "should" be.
How the coding proposal actually runs
For each invoice, the skill counts how that vendor's prior invoices have been coded and proposes whichever account carries the largest share of that history, with confidence equal to that share exactly, not weighted by how similar a line description or amount looks. A vendor coded to one account on every invoice in its history produces 100% confidence; a vendor split across two accounts produces a lower, honestly stated confidence on the majority account. Every proposal's basis, the exact count behind it, rides along on the register, so a reviewer can check the math without re-deriving it. A vendor with no history, or a proposed account missing or inactive in the chart of accounts, is never coded; it's routed to the exception queue instead, and an invoice always lands in exactly one of coded-with-confidence or exception, a reconciliation check the workbook itself enforces.
What it won't do
It won't code anything from a line description or an amount pattern alone; the only basis for a proposal is a vendor's own historical coding frequency against an active chart-of-accounts code. It won't guess at a vendor whose coding has recently and materially shifted; it reports the historical pattern and lets a reviewer judge whether the shift is intentional. It won't treat a duplicate invoice_id as two separate invoices; a repeated ID makes the population ambiguous and is rejected outright rather than coded twice. And it won't post anything to the ledger or an ERP; the coded register and exception queue are handed off for whatever approval and posting process AP already runs.
Inputs and output
| Inputs | An invoice extract (vendor, amount, invoice date, line description), the chart of accounts with an active/inactive flag, and historical coding frequency by vendor and account |
| Output | A coded invoice register with a confidence score and basis per line, and a separate exception queue for new vendors, inactive accounts, below-floor confidence, and parse failures |
| Who it's for | AP staff and the staff accountants who code the invoice backlog every cycle, and whoever reviews and releases coding before it reaches the ERP |
Adopt's agents propose the code your own vendor history already supports, show the exact count behind every proposal, and hand anything new, inactive, or uncertain to a reviewer instead of guessing. Nothing posts to your books until someone approves it. Sign up free, or book a pilot to run it against a month of your own invoice backlog.
invoice-coding.mdInvoice Extraction and GL Coding
This skill assumes invoice text has already been extracted into structured rows — it proposes coding from that structured data, it does not perform PDF or inbox parsing itself. Its job is the coding decision: which account, on what basis, and at what confidence — never a guess dressed up as a model score.
The gate
A proposal is not "coded" until it is earned by history and a usable account. The script refuses to produce a clean workbook when:
- An invoice is accounted for in neither bucket. Every invoice lands in exactly one of coded-with-confidence or exception — a reconciliation check enforces this and blocks the workbook if it ever fails.
- An amount or date does not parse. Reported as a named exception on the row it came from, never dropped or defaulted.
- A duplicate
invoice_idmakes the population ambiguous and is rejected outright.
A vendor with no historical coding, a proposed account that is missing or inactive in the chart
of accounts, and anything below --min-confidence are not gate failures — they are the
exception queue working as intended, and the workbook is written with them clearly flagged.
Inputs
- Invoice extract —
invoice_id, vendor, amount, invoice_date, line_description. - Chart of accounts —
account_code, account_name, active(active: y/n). - Historical coding —
vendor, account_code, invoice_count, a frequency table of how often each vendor was coded to each account historically.
Step 1 — Run the proposal
python3 scripts/invoice_coding.py --invoices invoices.csv --coa chart_of_accounts.csv \
--historical historical_coding.csv --min-confidence 60 \
--client "Northwind Systems Inc" \
--out "Northwind - Invoice Coding Register.xlsx"
Omit --min-confidence only if you deliberately want no confidence floor — every
historically-coded vendor with an active account then gets auto-coded, and only brand-new
vendors or inactive/missing accounts land in the exception queue. Stating a floor explicitly is
the safer default for anything going straight to posting.
Step 2 — How confidence is computed
Confidence is a real, auditable statistic, not an invented score: it is the share of a vendor's
historical invoice count that went to the account being proposed. A vendor coded to one account
100% of the time in history gets 100% confidence; a vendor split 60/40 between two accounts gets
60% confidence on the majority account and the minority 40% is simply the alternative not taken.
The basis column on the register always shows the underlying count (8/10 historical invoices to 6100), so a reviewer can second-guess the proposal without re-deriving it.
Deliver
Workbook tabs:
- Summary — population counts, coded vs. exception split, gate status.
- Coded Invoice Register — every auto-coded invoice with proposed account, confidence, and basis.
- Exception Queue — new vendors, inactive/missing accounts, below-floor confidence, and any parse failures, each with a stated reason.
- Vendor Coding History — the frequency table as supplied, for reference during review.
Then, in chat: the coded/exception split, the confidence floor applied, and anything that blocked the gate. Name the largest-dollar exceptions first — that's where review time belongs.
What to escalate
- A vendor whose historical coding has shifted materially (e.g., recently split differently than history suggests) — the script cannot detect intent behind a shift, only report the historical pattern.
- An invoice coded to an expense account that is unusually large relative to that vendor's historical invoice amounts — a dollar-size outlier even at high coding confidence.
- A chart of accounts with recently deactivated accounts still carrying heavy vendor history — may signal a chart cleanup that hasn't been communicated to AP.
- A new vendor whose name is a close variant of an existing vendor — possible duplicate vendor master record, not a coding question but worth flagging while the exception is in front of a reviewer.
Report each as a fact with the record reference. Do not characterize intent — describe what the data shows and let the reviewer decide.
Security posture
Fully local: standard library plus openpyxl. No network calls, no uploads, no telemetry.
Vendor and invoice data never leave the machine.
Dependencies
pip install openpyxl
Built by Adopt AI — free to use and modify.
Appendix — bundled files
If you installed the .skill package these files are already in place and you can ignore this appendix. If you copied the skill as text, create the files below at the paths shown, alongside your SKILL.md. The skill will not run without them.
scripts/invoice_coding.py
#!/usr/bin/env python3
"""
Propose GL coding for vendor invoices from historical coding frequency, and
route everything that cannot be proposed safely to an exception queue.
Gates:
* a vendor with no historical coding rows is an exception - never guessed
from the line description or any category heuristic
* a proposed account_code must exist in the chart of accounts AND be active,
or it is an exception
* every invoice lands in exactly one bucket: coded-with-confidence, or
exception - never left blank, never double-counted
* an unparseable amount or date is a visible parse exception, never coerced
or dropped silently
* if --min-confidence is supplied, anything below it is routed to the
exception queue instead of being auto-coded
Usage - emit a blank invoice intake template:
python3 invoice_coding.py --template --out invoices_template.csv
Usage - propose coding:
python3 invoice_coding.py --invoices invoices.csv --coa chart_of_accounts.csv \
--historical historical_coding.csv --min-confidence 60 \
--client "Northwind Systems Inc" \
--out "Northwind - Invoice Coding Register.xlsx"
--invoices CSV:
invoice_id, vendor, amount, invoice_date, line_description
--coa CSV (chart of accounts):
account_code, account_name, active (active: y/n)
--historical CSV (historical coding frequency):
vendor, account_code, invoice_count
"""
from __future__ import annotations
import argparse
import csv
import sys
from collections import defaultdict
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
try:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
except ImportError:
sys.exit("openpyxl is required. pip install openpyxl")
ZERO = Decimal("0.00")
MONEY = '#,##0.00;[Red](#,##0.00)'
PCT = '0.0"%"'
DATEF = "yyyy-mm-dd"
HDR_FILL = PatternFill("solid", fgColor="1F3864")
HDR_FONT = Font(bold=True, color="FFFFFF")
OK_FONT = Font(bold=True, color="006100")
BAD_FONT = Font(bold=True, color="9C0006")
BAD_FILL = PatternFill("solid", fgColor="FFC7CE")
WARN_FILL = PatternFill("solid", fgColor="FFEB9C")
INVOICE_COLS = ["invoice_id", "vendor", "amount", "invoice_date", "line_description"]
COA_COLS = ["account_code", "account_name", "active"]
HIST_COLS = ["vendor", "account_code", "invoice_count"]
def dec(raw):
if raw is None:
return None
s = str(raw).strip()
if s in ("", "-", "--", "n/a", "N/A", "None"):
return None
neg = s.startswith("(") and s.endswith(")")
s = s.strip("()").replace(",", "").replace("$", "").strip()
if s.endswith("-"):
neg, s = True, s[:-1]
try:
v = Decimal(s)
except InvalidOperation:
raise ValueError(f"Unparseable amount: {raw!r}")
return -v if neg else v
def clean(s) -> str:
return " ".join(str(s or "").split())
def norm_vendor(s: str) -> str:
return clean(s).lower()
def pdate(raw):
s = clean(raw)
if not s:
return None
for f in ("%Y-%m-%d", "%m/%d/%Y", "%m/%d/%y", "%d-%b-%Y", "%Y/%m/%d"):
try:
return datetime.strptime(s.split(" ")[0], f).date()
except ValueError:
continue
raise ValueError(f"Unrecognized date {raw!r}. Prefer ISO YYYY-MM-DD.")
def read_csv(path: Path, cols: list[str]) -> list[dict]:
with path.open(newline="", encoding="utf-8-sig") as fh:
reader = csv.DictReader(fh)
missing = [c for c in cols if c not in (reader.fieldnames or [])]
if missing:
sys.exit(f"{path}: missing required column(s) {missing}")
rows = list(reader)
if not rows:
sys.exit(f"No rows in {path}")
return [{k: clean(r.get(k)) for k in cols} for r in rows]
def write_template(path: Path) -> None:
with path.open("w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=INVOICE_COLS)
w.writeheader()
w.writerow({
"invoice_id": "INV-1001", "vendor": "Acme Supply Co",
"amount": "1250.00", "invoice_date": "2026-01-15",
"line_description": "office supplies - January",
})
print(f"Template written: {path}")
def load_coa(path: Path) -> dict:
coa = {}
for r in read_csv(path, COA_COLS):
code = r["account_code"]
if not code:
continue
coa[code] = {
"account_name": r["account_name"],
"active": r["active"].strip().lower() in ("y", "yes", "true", "1"),
}
return coa
def load_historical(path: Path) -> dict:
hist = defaultdict(dict)
errors = []
for i, r in enumerate(read_csv(path, HIST_COLS), start=1):
vendor = norm_vendor(r["vendor"])
if not vendor or not r["account_code"]:
continue
try:
count = int(Decimal(r["invoice_count"]))
except (InvalidOperation, ValueError):
errors.append(f"historical row {i}: unparseable invoice_count {r['invoice_count']!r}")
continue
hist[vendor][r["account_code"]] = hist[vendor].get(r["account_code"], 0) + count
return hist, errors
def load_invoices(path: Path) -> tuple[list[dict], list[str]]:
errors = []
out = []
seen_ids = {}
with path.open(newline="", encoding="utf-8-sig") as fh:
reader = csv.DictReader(fh)
missing = [c for c in INVOICE_COLS if c not in (reader.fieldnames or [])]
if missing:
sys.exit(f"{path}: missing required column(s) {missing}")
for i, raw in enumerate(reader, start=2): # row 1 is header
r = {k: clean(raw.get(k)) for k in INVOICE_COLS}
r["row"] = i
if not r["invoice_id"]:
errors.append(f"row {i}: blank invoice_id - cannot be scanned or deduped")
continue
if r["invoice_id"] in seen_ids:
errors.append(
f"row {i}: invoice_id {r['invoice_id']!r} duplicates row "
f"{seen_ids[r['invoice_id']]} - population is ambiguous"
)
continue
seen_ids[r["invoice_id"]] = i
try:
r["amount_dec"] = dec(r["amount"])
if r["amount_dec"] is None:
raise ValueError("blank amount")
except ValueError as e:
errors.append(f"row {i} ({r['invoice_id']}): {e}")
continue
try:
r["invoice_date_dec"] = pdate(r["invoice_date"])
except ValueError as e:
errors.append(f"row {i} ({r['invoice_id']}): {e}")
continue
out.append(r)
if not out and not errors:
sys.exit(f"No invoices in {path}")
return out, errors
def propose(invoices: list[dict], coa: dict, hist: dict, min_conf) -> tuple[list[dict], list[dict]]:
coded, exceptions = [], []
for inv in invoices:
vkey = norm_vendor(inv["vendor"])
vhist = hist.get(vkey)
if not vhist:
exceptions.append({**inv, "reason": "no historical coding for this vendor - new vendor, route for manual coding"})
continue
total = sum(vhist.values())
ranked = sorted(vhist.items(), key=lambda kv: (-kv[1], kv[0]))
account_code, count = ranked[0]
confidence = (Decimal(count) / Decimal(total) * 100).quantize(Decimal("0.1"))
acct = coa.get(account_code)
if acct is None:
exceptions.append({**inv, "reason": f"proposed account {account_code} not found in chart of accounts"})
continue
if not acct["active"]:
exceptions.append({**inv, "reason": f"proposed account {account_code} ({acct['account_name']}) is inactive"})
continue
if min_conf is not None and confidence < min_conf:
exceptions.append({
**inv,
"reason": f"confidence {confidence}% below floor {min_conf}% "
f"(would propose {account_code} on {count}/{total} history)",
})
continue
coded.append({
**inv,
"account_code": account_code,
"account_name": acct["account_name"],
"confidence": confidence,
"basis": f"{count}/{total} historical invoices to {account_code}",
})
return coded, exceptions
def style_header(ws, cols):
for c, name in enumerate(cols, start=1):
cell = ws.cell(row=1, column=c, value=name)
cell.fill = HDR_FILL
cell.font = HDR_FONT
cell.alignment = Alignment(horizontal="center")
ws.freeze_panes = "A2"
def autosize(ws, cols, rows):
for c, name in enumerate(cols, start=1):
width = max(len(name), *(len(str(r.get(name, ""))) for r in rows)) if rows else len(name)
ws.column_dimensions[get_column_letter(c)].width = min(max(width + 2, 10), 48)
def sheet_summary(wb, meta, n_coded, n_exc, n_parse_err, valid):
ws = wb.active
ws.title = "Summary"
ws["A1"] = "INVOICE EXTRACTION AND GL CODING"
ws["A1"].font = Font(bold=True, size=14)
row = 3
for k, v in meta.items():
ws.cell(row=row, column=1, value=k).font = Font(bold=True)
ws.cell(row=row, column=2, value=v)
row += 1
row += 1
ws.cell(row=row, column=1, value="Coded with confidence").font = Font(bold=True)
ws.cell(row=row, column=2, value=n_coded)
row += 1
ws.cell(row=row, column=1, value="Exceptions").font = Font(bold=True)
ws.cell(row=row, column=2, value=n_exc)
row += 1
ws.cell(row=row, column=1, value="Parse errors (gate)").font = Font(bold=True)
ws.cell(row=row, column=2, value=n_parse_err)
row += 2
status = ws.cell(row=row, column=1, value="GATE STATUS: " + ("CLEAN" if valid else "NOT MET"))
status.font = OK_FONT if valid else BAD_FONT
ws.column_dimensions["A"].width = 32
ws.column_dimensions["B"].width = 50
def sheet_coded(wb, coded):
ws = wb.create_sheet("Coded Invoice Register")
cols = ["invoice_id", "vendor", "invoice_date", "amount", "line_description",
"account_code", "account_name", "confidence", "basis"]
style_header(ws, cols)
for r, inv in enumerate(coded, start=2):
ws.cell(row=r, column=1, value=inv["invoice_id"])
ws.cell(row=r, column=2, value=inv["vendor"])
dc = ws.cell(row=r, column=3, value=inv["invoice_date_dec"]); dc.number_format = DATEF
ac = ws.cell(row=r, column=4, value=float(inv["amount_dec"])); ac.number_format = MONEY
ws.cell(row=r, column=5, value=inv["line_description"])
ws.cell(row=r, column=6, value=inv["account_code"])
ws.cell(row=r, column=7, value=inv["account_name"])
pc = ws.cell(row=r, column=8, value=float(inv["confidence"])); pc.number_format = PCT
ws.cell(row=r, column=9, value=inv["basis"])
autosize(ws, cols, [])
def sheet_exceptions(wb, exceptions, parse_errors):
ws = wb.create_sheet("Exception Queue")
cols = ["invoice_id", "vendor", "invoice_date", "amount", "reason"]
style_header(ws, cols)
r = 2
for inv in exceptions:
ws.cell(row=r, column=1, value=inv["invoice_id"])
ws.cell(row=r, column=2, value=inv["vendor"])
ws.cell(row=r, column=3, value=str(inv.get("invoice_date_dec", inv.get("invoice_date", ""))))
amt = inv.get("amount_dec")
ws.cell(row=r, column=4, value=float(amt) if amt is not None else inv.get("amount", ""))
rc = ws.cell(row=r, column=5, value=inv["reason"])
for c in range(1, 6):
ws.cell(row=r, column=c).fill = WARN_FILL
r += 1
if parse_errors:
ws.cell(row=r, column=1, value="-- parse errors (gate failures) --").font = BAD_FONT
r += 1
for e in parse_errors:
cell = ws.cell(row=r, column=1, value=e)
cell.fill = BAD_FILL
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=5)
r += 1
autosize(ws, cols, exceptions)
def sheet_history(wb, hist):
ws = wb.create_sheet("Vendor Coding History")
cols = ["vendor", "account_code", "invoice_count", "share_of_vendor"]
style_header(ws, cols)
r = 2
for vendor in sorted(hist):
total = sum(hist[vendor].values())
for code, count in sorted(hist[vendor].items(), key=lambda kv: -kv[1]):
ws.cell(row=r, column=1, value=vendor)
ws.cell(row=r, column=2, value=code)
ws.cell(row=r, column=3, value=count)
share = ws.cell(row=r, column=4, value=float((Decimal(count) / Decimal(total) * 100).quantize(Decimal("0.1"))))
share.number_format = PCT
r += 1
autosize(ws, cols, [])
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--invoices")
ap.add_argument("--coa")
ap.add_argument("--historical")
ap.add_argument("--out", required=True)
ap.add_argument("--min-confidence", type=float, default=None,
help="invoices proposed below this confidence become exceptions. "
"If omitted, no floor is applied.")
ap.add_argument("--client", default="")
ap.add_argument("--template", action="store_true",
help="emit a blank invoice intake template instead of coding")
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
if args.template:
write_template(Path(args.out))
return 0
if not (args.invoices and args.coa and args.historical):
sys.exit("--invoices, --coa, and --historical are all required (unless --template)")
coa = load_coa(Path(args.coa))
hist, hist_errors = load_historical(Path(args.historical))
invoices, parse_errors = load_invoices(Path(args.invoices))
coded, exceptions = propose(invoices, coa, hist, args.min_confidence)
total_in = len(invoices) + len(parse_errors)
accounted = len(coded) + len(exceptions) + len(parse_errors)
errors = list(hist_errors) + list(parse_errors)
if accounted != total_in:
errors.append(
f"internal reconciliation failure: {accounted} rows accounted for vs "
f"{total_in} rows read - every invoice must land in exactly one bucket"
)
valid = not errors
meta = {
"Client": args.client or "(not stated)",
"Invoices read": total_in,
"Confidence floor": f"{args.min_confidence}%" if args.min_confidence is not None else "none applied",
"Prepared": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
print("=" * 76)
print("INVOICE EXTRACTION AND GL CODING")
print("=" * 76)
print(f"Client : {meta['Client']}")
print(f"Invoices read : {total_in}")
print(f"Coded w/ confidence: {len(coded)}")
print(f"Exceptions : {len(exceptions)}")
print(f"Confidence floor : {meta['Confidence floor']}")
if errors:
print(f"\nGATE FAILURES ({len(errors)}):")
for e in errors[:25]:
print(f" ! {e}")
if len(errors) > 25:
print(f" ... and {len(errors) - 25} more")
if not valid and not args.force:
print("\n" + "=" * 76)
print("WORKBOOK NOT WRITTEN.")
print("Every invoice must be accounted for with a parseable amount and date, and")
print("the historical/invoice population must reconcile row-for-row. Fix the")
print("flagged rows and re-run.")
print("=" * 76)
return 1
wb = Workbook()
sheet_summary(wb, meta, len(coded), len(exceptions), len(parse_errors), valid)
sheet_coded(wb, coded)
sheet_exceptions(wb, exceptions, parse_errors)
sheet_history(wb, hist)
wb.active = 0
out = Path(args.out)
if not valid:
out = out.with_name(out.stem + " [GATE NOT MET]" + out.suffix)
out.parent.mkdir(parents=True, exist_ok=True)
wb.save(str(out))
print(f"\n{'GATE CLEAN' if valid else 'NOT MET (forced)'}. Workbook: {out}")
return 0 if valid else 1
if __name__ == "__main__":
sys.exit(main())
Does this skill read the invoice PDF or email itself?
No. It assumes invoice text has already been extracted into structured rows, vendor, amount, invoice date, line description, and proposes coding from that structured data. The extraction step itself, PDF or inbox parsing, happens before this skill runs; its own job is strictly the coding decision.
What does it do with a vendor it has never seen before?
A new vendor has no coding history to propose from, so every one of its invoices lands in the exception queue rather than guessed at from the vendor's name or category. That's not treated as a gate failure; it's the exception queue working exactly as intended.
How is the confidence score on a proposed code calculated?
It's a real, auditable statistic, not an invented score: the share of a vendor's historical invoice count that went to the account being proposed. A vendor coded to one account 100% of the time in history gets 100% confidence; a vendor split 60/40 between two accounts gets 60% confidence on the majority account. The basis column always shows the underlying count, such as "8/10 historical invoices to 6100," so a reviewer can second-guess the proposal without re-deriving it.
What happens with an inactive or missing GL account?
A proposed account that's missing or inactive in the chart of accounts is routed to the exception queue, the same as a new vendor or a below-floor confidence score, rather than coded to an account that no longer accepts postings.
Does invoice or vendor data leave the machine?
No. Coding proposals run fully local using the Python standard library plus openpyxl. No vendor names, invoice amounts, or coding history are uploaded or sent to a cloud service.

Install
Install with the CLI
Detects which agents you have and asks where to install. Works with Claude Code, Codex, Cursor, Windsurf, and anything else supporting the Agent Skills spec.
npx skills add adoptai/cpa-skills --skill invoice-codingother ways
Install as a Claude Code plugin
Adds the repo as a marketplace, then installs all skills together as the cpa-skills plugin.
/plugin marketplace add adoptai/cpa-skills/plugin install cpa-skillsDownload the .skill file
invoice-coding.skillClone, submodule, and fork instructions are in the repository README.
Details
- Version
- v1.0.0
- Identifier
invoice-coding- Category
- Document extraction and conversion
- Source
- View on GitHub
What you need
- Python 3.9+
- pip install openpyxl
