Caleb J. Gammon
Case study

Automating a weekly invoicing pipeline

Client: a marine service company operating across roughly 17 sites in South Florida
Problem: a recurring manual invoice export, done by hand every week
Result: one scheduled pipeline, validated line by line against the manual output it replaced


Before

Every Monday, someone pulled the prior week’s completed work orders out of the company’s field-service platform, generated an invoice PDF for each one, sorted them into folders by the day the work was completed, zipped the week, and emailed the archive for review.

Sixty work orders in a typical week. Entirely manual. The kind of task that is too small to justify a vendor and too regular to keep doing by hand.

After

One command, or a Monday cron. The pipeline computes the prior week’s window in the business’s local timezone, pulls completed orders through the platform’s API, builds an invoice view model per order, renders a PDF, buckets by local completion day, zips the week, and emails it. Every run emits structured logs, and a dry-run mode executes the full pipeline while sending nothing.

The output is byte-for-byte the same shape as the manual export, so nobody downstream had to change anything about how they work.

How I knew it worked

I rebuilt one week that had already been produced by hand, then compared my output against it. Sixty orders, four independent checks:

CheckResult
Correct orders selected60 of 60, exact set match, none missing, none extra
Bucketed into the correct day folder60 of 60
Invoice text, word for word60 of 60 across roughly 8,700 words
Financial totals: subtotal, total due, amount paid60 of 60 unchanged

Two things make that table worth something. The reference export was produced independently of my pipeline, by the manual process, before I wrote a line of code. And I did not choose the week to flatter the result; it was simply the week the reference covered.

Layout was compared separately by measuring text positions off both sets of PDFs. Median vertical offset was 0.6 points, with the worst case at 11.5 points, about a sixth of an inch.


What made this harder than it looked

The timezone bug that would have looked fine for months

The API returns completion time as an absolute instant in UTC. Bucketing on that timestamp is the obvious implementation, and it is wrong.

Ten of the sixty reference orders were completed after 8pm Eastern. In UTC those belong to the following day. Bucketing naively would have misfiled 17 percent of the week into a folder that should not exist at all.

This is the failure mode I care most about: it produces output that looks correct. The PDFs render, the totals are right, the zip arrives, and roughly one invoice in six is quietly in the wrong place. It surfaces weeks later as a client asking why an invoice is dated wrong, and by then nobody connects it to the automation.

The pipeline now requires an explicit timezone and refuses to run if it is unset or invalid. Failing loudly on a missing configuration value is cheaper than a plausible wrong answer.

The documentation was wrong in ways that cost real time

Four findings, each of which broke the build until it was understood:

The only date filter in the documentation is null on every completed order. Filtering by it returns almost nothing. The field that actually works is undocumented. Nothing in the docs suggests this, and the failure is silent: you get an empty result set, which is indistinguishable from a quiet week.

Timestamps must be sent in UTC with a Z suffix. Any offset format returns a 400. The pipeline throws on any non-2xx response rather than continuing, precisely because a swallowed 400 looks exactly like a week with no work in it.

Line items are not in the list response. They require a separate call per order, which changes the shape of the whole ingest.

The current API version has no token exchange. The published integration guidance described a token flow from the previous version, which no longer exists.

Tax semantics, and a bug I decided to fix

An order’s tax entries carry a name and a rate but not a type. That omission matters, because one of the charges in this company’s tax registry is a fixed per-unit fee, not a percentage. Read it as a percentage and the number is wrong. Percentage-based taxes then compound on top of it. Getting this right required a separate, also undocumented, endpoint that returns the company’s tax registry with the type included.

While validating, I found something in the original invoice template. The three totals labels were static text, and the values beside them were rendered as a separate, vertically centered column. Labels and values did not correspond. On at least one real invoice, a disposal charge printed on the line labeled sales tax.

I fixed it. Each charge now prints against its own label, all three rows always render, and any unrecognized tax is summed into the appropriate row rather than overflowing the layout.

Then I verified that the fix changed no money. Across all sixty invoices, subtotal, total due, and amount paid are identical to the references. The only thing that changed is which label a charge sits on.

One quirk in the original template I deliberately kept: a word is misspelled in a heading on every historical invoice. Preserving it keeps new invoices consistent with the archive. Correctness and consistency are not always the same decision, and it is worth being explicit about which one you are choosing.

Not leaking internal notes to customers

Line items can carry comments, and comments have a visibility flag. Some are internal. In the reference week, one order carries a private note that its invoice correctly does not show, while another order with the same product and the same comment text, marked public, does show it.

The pipeline requires public visibility before a comment renders. Drop that check and internal notes go out to customers on company letterhead. This is a two-line condition that is easy to never write, and the corpus contains exactly one example that would have exposed it.

The 25 MB email limit that is really 18 MB

The week goes out as a single zip. Mail providers commonly cap attachments at 25 MB, but that cap applies to the base64-encoded message, which runs about 1.37 times the raw size. The real raw ceiling is closer to 18 MB.

The reference week produced an 8.5 MB zip, leaving room for roughly 130 orders. If a week ever exceeds the configured limit, it is split into numbered parts and sent as several emails rather than silently bouncing.

Most of that headroom came from one change. The company logo was stored as a PNG with an alpha channel it did not need, since the invoice background is white. The alpha channel forced the renderer to embed a separate softmask in every PDF, costing about 115 KB per file, roughly 55 percent of the total. Flattening the logo onto white cut each PDF from 288 KB to 172 KB at identical pixel dimensions and resolution. No downscaling, no visible difference.


What this pipeline does not do, and does not claim

The reference week is one week. It does not exercise everything, and I would rather say so than imply coverage I do not have.

  • Multi-page invoices. All sixty references fit on one page. Page-two behavior is untested.
  • Non-zero amounts paid, discounts, populated client addresses, and any phone number that is not an 11-digit US number. None occur in the corpus. The phone formatting rule is fit to a single sample and passes anything else through unchanged rather than mangling it.
  • The fixed-fee tax appears on exactly one order. Its behavior is confirmed by the tax registry endpoint but exercised only once in practice.

One more thing the pipeline does not do: it never writes to the source system. The manual process flips each order to a “sent” status after emailing. This tool reads that status but does not set it. Adding write access to a client’s system of record is a decision to make deliberately, not a convenience to slip in.

Why one cent is off, and why I left it

On one order, the printed subtotal plus the printed tax line is a cent below the printed total due. This is inherited, not introduced. The source system computes the total with exact decimal arithmetic, while the template prints the tax line using floating-point rounding.

The pipeline reads the total from the source and never recomputes it. Recomputing would have made the invoice internally consistent and made it disagree with the company’s own books. Matching the system of record is the more important property.


Stack

Node.js, Puppeteer for PDF rendering, an HTML invoice template authored in points because the original was printed at about 70 percent scale, SMTP delivery, GitHub Actions for scheduling.

Source is private. Happy to walk through the architecture.