The night I wrote this check, I started by sabotaging my own workbook. I took ten dummy invoices and planted three mistakes: one row removed, one amount with two digits swapped, and one amount stored as text. Then I tried to spot the problem by eye from the total cell. I couldn't. The workbook said 276,470; the source said 291,650. A gap of 15,180 doesn't point at anything.
Here is the idea I'd like to leave first: whoever did the transcription, keep the check on a separate path. I'm not claiming Cowork produces these three errors. They are simply the shapes that show up whether a person, a macro, or an AI does the copying, so it's worth listing them before they cost you a month-end.
Three ways a transcription drifts
I used ten made-up invoices across three vendors. No real data is involved. The source is a CSV, the transcribed result is an Excel workbook, and I planted these:
| Drift | What I planted | Why it's hard to see |
|---|---|---|
| Missing row | Removed INV-1005 (5,400) | Ten rows become nine; the list still looks natural |
| Swapped digits | INV-1007 changed from 22,750 to 22,570 | Same length, same look |
| Amount as text | INV-1003 entered as the string "9,600" | Looks like a number on screen, but SUM skips it |
The third one was the nastiest. The total cell held =SUM(D2:D10), and a text "9,600" isn't added, so Excel shows 9,600 less than even the plain sum of what's actually there (286,070). The errors stack, and you can no longer pin the gap on a single cause.
Match by key, not by total
The first instinct is to compare totals. But a mismatch tells you nothing about which row is responsible, and some errors cancel out (+100 in one row, -100 in another).
So I join on the invoice number and check four things:
- Rows in the source but not in the workbook (missing)
- Rows in the workbook but not in the source (extra)
- Same invoice number, different amount (mismatch)
- Amount stored as text instead of a number (type)
Counts and totals are printed at the end as context only. If the rows match, the total matches. The reverse doesn't hold. I try to keep that direction straight.
The script
It expects a source CSV with invoice_no,date,vendor,amount and a workbook with the same four columns. Rows that aren't invoices, such as the total row, are skipped by checking for the INV- prefix.
"""Verify a transcribed workbook against its source CSV."""
import csv, sys
from decimal import Decimal, InvalidOperation
import openpyxl
def to_amount(v):
"""Normalize numbers and strings like "9,600" or "¥9,600" to Decimal; None if unreadable."""
if isinstance(v, (int, float)):
return Decimal(str(v))
try:
return Decimal(str(v).replace(",", "").replace("¥", "").replace("¥", "").strip())
except (InvalidOperation, AttributeError):
return None
def load_source(path):
with open(path, encoding="utf-8") as f:
return {r["invoice_no"]: Decimal(r["amount"]) for r in csv.DictReader(f)}
def load_sheet(path):
ws = openpyxl.load_workbook(path).active # formula cells come back as formula strings
rows, raw_types = {}, {}
for r in ws.iter_rows(min_row=2, values_only=True):
no, _date, _vendor, amt = r[:4]
if not no or not str(no).startswith("INV-"): # skip the total row etc.
continue
rows[no] = to_amount(amt)
raw_types[no] = type(amt).__name__
return rows, raw_types
def main(src, xlsx):
s = load_source(src)
t, types = load_sheet(xlsx)
problems = []
for no in sorted(s.keys() - t.keys()):
problems.append(f"missing {no}: source has {s[no]:,} but the workbook has no row")
for no in sorted(t.keys() - s.keys()):
problems.append(f"extra {no}: not in the source")
for no in sorted(s.keys() & t.keys()):
if t[no] is None:
problems.append(f"invalid {no}: amount cannot be read as a number")
elif t[no] != s[no]:
problems.append(f"mismatch {no}: source {s[no]:,} / workbook {t[no]:,}")
if types.get(no) == "str":
problems.append(f"type {no}: amount is stored as text (SUM will skip it)")
print(f"source rows {len(s)} / workbook rows {len(t)}")
print(f"source total {sum(s.values()):,} / workbook total {sum(v for v in t.values() if v is not None):,}")
for p in problems:
print(" -", p)
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2]))Run it with python3 verify_transcription.py source_invoices.csv transcribed.xlsx. On my three planted errors, it printed this (the labels are in Japanese in my original run; I translated them here):
source rows 10 / workbook rows 9
source total 291,650 / workbook total 286,070
- missing INV-1005: source has 5,400 but the workbook has no row
- type INV-1003: amount is stored as text (SUM will skip it)
- mismatch INV-1007: source 22,750 / workbook 22,570The exit code is 1 when anything is found and 0 otherwise, which matters in the handoff below.
Why Decimal, and why convert text first
Comparing floats breaks on invoices with fractional amounts, such as tax rounding, where values that look identical differ by a binary rounding error. Normalizing through Decimal(str(v)) avoids that kind of false alarm.
I also convert the text "9,600" to a number before comparing, because I wanted to tell apart "the value is right but the container is wrong" from "the value is wrong". Different causes, different fixes: a type problem is solved by resetting the column format to numbers; a mismatch means going back to the source.
There's a limit worth stating. The script doesn't compare dates or vendor names. The same structure extends to them easily, but I started with amounts only because a check that reports everything on day one tends to produce output nobody reads.
How I hand it to Cowork, and where I draw the line
When I ask Cowork to transcribe something, I leave this script in the same folder as the source and end my request with one sentence:
When you finish, run
verify_transcription.pyin the same folder and report its output as is. Fix any flagged rows only after reporting.
The key part is seeing the output before anything is fixed. If the fix happens first, I never learn what actually went wrong. With the output in hand, I can tell a missing row from a swapped digit and adjust my next request (for example, "keep the amount column numeric").
Still, whoever ran the script is the same party that did the transcription. For months that matter, I run the same command myself in my own terminal. The one who copies and the one who checks should be different hands as far as possible. As an indie developer, I hold to that when I hand invoice entry to a contractor, too.
One small next step
Pick one already-transcribed workbook and find its source CSV. Then change just two things in the script, the INV- prefix and the column order, to match your own data. If the first run prints nothing, you've learned the sheet really is right. If it prints something, you've got your next prompt to rewrite.