Problem
Every billing period, someone turned the delivery log into one invoice per store by hand. The source data made that slow and risky. The same store was typed several ways, product names were misspelled, prices and units were sometimes blank, and both invoice-number columns were corrupted. A broken fill-down had copied one invoice value across about 45 unrelated stores, so grouping by those columns would have merged dozens of stores into one invoice.
Invoices are printed and hand-delivered, so a wrong one reaches a store owner on paper. The constraint was that speed could not cost correctness.
My role
I was the sole engineer, working through Renvane, my consulting agency. I scoped the work with the client, wrote the proposal, built the pipeline and web UI, deployed it, and tested the Zoho import. The git history shows one author. KareApp supplied the workbook and the billing rules, and confirmed the open rules (returns, due dates, invoice numbering).
How it works
.xlsx workbook
|
v
Workbook agent (Claude, read-only tools) proposes a mapping plan
|
v
Ingestion (openpyxl) --> per-run DuckDB unpivot wide log into line items
|
v
Validation: exact match -> learned alias -> Claude suggestion (catalog list or NONE; difflib fallback)
| \
| clean rows flagged rows --> Flask review screen
| |
| decisions + learned aliases <-- SQLite / alias JSON
v
Export gate (agent path): refuses while any blocking issue is open
|
v
59-column Zoho CSV + per-period export logThe operator uploads the workbook and picks a billing period (1st to 15th, or 16th to month end). The workbook agent inspects the file through bounded read-only tools and proposes which sheet is the delivery log and where the store list and prices live. Plain code then applies that plan, unpivoting the wide delivery log into one line item per non-zero cell.
Each line is resolved against the master catalog. Safe problems are auto-fixed and reported. Names that miss every lookup go to Claude Haiku, which is asked to pick from the catalog list or answer NONE. If there is no API key or the call fails, difflib similarity takes over. Every suggestion becomes a blocking issue with a pre-filled fix and a confidence label, and the reviewer accepts, corrects or skips it.
Key decisions
The model proposes, deterministic code applies. The workbook agent can only read. The alternative was letting an LLM emit normalized rows. On financial data, a wrong model description of a sheet is caught downstream, while silently altered numbers are not. Hardcoding the layout was also out, because the client changed workbook formats mid-engagement and the code now detects and loads both schemas.
A review gate instead of auto-applying the best guess. Only provably safe fixes apply automatically. The one exception is aliases a human already approved. A price that differs from the master sheet is flagged with both values and not corrected, because it can be a typo or a negotiated exception. Confirmed name fixes are remembered by default, so a recurring quirk is reviewed once.
Rebuild invoice numbers from the trustworthy fragments. I derive the number from the period prefix and the resolved store name, one invoice per store per period. Numbers over Zoho’s 50-character limit keep a readable prefix and get a short SHA-256 suffix, so truncation cannot merge similar store names.
Stable ids and fingerprints for review state. Unpivoted lines have no natural row number, so each gets an id of source row times 100 plus column index. The same workbook always yields the same ids, so a saved decision cannot land on a different delivery. A hash of the workbook is stored with the validation report, and a reviewed report that does not match the current workbook is rejected.
What was hard
The data was wrong in several independent ways, and the first two designs were wrong. The first build named customers by short code, and a later one by full registered name. Zoho identifies a customer by exact name, so every store appeared twice in the client’s account, as drafts. Nothing was sent or paid. I cleaned it up with a per-store keep/delete checklist and changed the loader to take names from one source only.
Zoho’s CSV dialect was the other trap. Header auto-matching broke on quoted header cells, so the output uses the default minimal quoting. I found this by importing into Zoho, not from the sample file.
The workbook is a rolling Google Drive export with overlapping snapshot tabs. Merging them would double-bill, so exactly one tab is elected per period.
Results
- Replaces a manual, per-period re-keying step. I have not measured the client’s invoice volume or time saved.
- The agent normalization path produces a mapping plan and a DuckDB dataset; I do not publish a client-data validation result here.
- About 4,500 lines of Python, including a plain-Python test suite (no pytest) covering the stale-report rejection, period handling, and invoice-number rules.
- The Zoho import dialect was verified by real imports into Zoho Invoice.
Limitations and next steps
The matching quality is not measured. I have no accuracy figure for Claude’s suggestions against reviewer decisions. The Zoho CSV is imported by hand. The web path is still labeled experimental in the repo. The agent and DuckDB pieces are newer than the original CLI. Later reconciliation and receipt-photo phases were proposed, not built.