All work

Blue Tees Golf · Product Manager · 2026

Expense Report Generator

An internal tool that turns line items and photographed receipts into a finished expense PDF, entirely in the browser, so receipts never leave the employee's device and there is no backend to run.

  • Internal Tooling
  • React
  • Zero-Backend
  • Privacy
0
Servers to run or patch
1
File out, correctly named

Context

Filing an expense report meant assembling a spreadsheet, photographing receipts, and mailing accounts payable a pile of attachments that arrived in whatever order the mail client chose. The output was inconsistent enough that the finance side spent real time reassembling it.

The obvious build is a small web app with a database and a file store. I built something narrower.

Approach

No backend. The whole report is assembled client-side: the form, the running total, the PDF. Receipts are held as local object URLs and drawn straight into the document. Nothing is uploaded, so there’s no storage bucket full of other people’s meal receipts, no retention question, and nothing to run or patch. It deploys as a static site.

Validation before export, not after submission. Required fields, an amount above zero on every item, and a description on every item, all checked before a PDF exists. The failure mode of the old process was a report that reached finance and bounced. The failure mode here is a button that tells you what’s missing.

A fixed output shape. Page one is the header and the itemised table with a total. Then receipt pages, paginated on the real height of each image rather than a guess, each labelled with its filename. Finance gets the same document every time, and the file arrives named for the employee and the report date instead of Scan_0043.pdf.

The bug that was worth avoiding. Reading an uploaded receipt back into the document is the obvious one-liner, and both obvious approaches, fetch() and FileReader, can be blocked by a production content security policy on blob: URLs. Loading the image through an Image element instead reuses the path the on-screen thumbnails already prove works, and re-encoding it through a canvas guarantees the image format declared to the PDF matches the actual bytes. An odd JPEG or a phone’s HEIC can’t silently produce a corrupt file. A receipt that still fails is labelled in the PDF rather than dropped from it.

The sign-in is not the control. Access is a company Google account, and the same domain check is repeated in the Firestore and Storage rules server-side. The client-side gate is a convenience. The rules are the boundary.

Outcome

  • Employees produce a consistent, finance-ready PDF in one pass, with receipts attached in order and the total already computed.
  • No server, no database of receipts, and no ongoing operational cost.

Artifacts

A domain-restricted Google sign-in leads into a dashed boundary marked "inside the browser, nothing below leaves the device", containing four steps: report header with name, department, date and business purpose; line items across airfare, hotel, dining, transport and other; receipts held as local blob URLs with no upload; and assembly with jsPDF, producing a header and itemised table page followed by paginated receipt pages. A note explains that every receipt is redrawn through a canvas first, because a production content security policy can block fetch and FileReader on blob URLs, and re-encoding guarantees the declared image format matches the actual bytes. Output: a finished PDF named for the employee and report date.
The whole tool. The dashed boundary marks what never gets uploaded.