DEV Community

Paperwork Kit
Paperwork Kit

Posted on

A rent receipt generator in one HTML file: print CSS, localStorage, and the bug I shipped

I needed a rent receipt generator for a small landlord. The obvious answer was a SaaS: sign up, store tenants in a database, email PDFs. I built the opposite: one HTML file, no server, no build step, that prints a one-page PDF from the browser. Here is what that constraint taught me, and where it breaks.

The constraint

  • One file. Open it from disk or from a static host, it works the same.
  • No external scripts, no fonts from a CDN, no analytics. The file must work offline in 2036.
  • State survives a reload, but never leaves the machine.
  • The printed result must be a clean Letter or A4 page, not a screenshot of a web form.

Three things that did most of the work

1. Two layouts in one document, switched by @media print.

The page has a form on the left and the document on the right. The print stylesheet hides everything with a .noprint class and lets the document fill the page:

@page { size: auto; margin: 18mm; }
@media print {
  .noprint { display: none !important; }
  body { background: #fff; }
  .sheet { box-shadow: none; width: auto; margin: 0; }
}
Enter fullscreen mode Exit fullscreen mode

"Save as PDF" in Chrome, Edge, Firefox and Safari all go through the same path, so the user never installs anything. The trap: a header that is 2 px too tall pushes a signature line onto page two. I now test every tool headless with --print-to-pdf and count the pages in the output (a regex on /Type /Page is enough).

2. localStorage as the whole backend.

Every input has an id. On input, the tool serialises the fields to one JSON blob under a versioned key; on load, it restores them. That is the entire persistence layer:

const FIELDS = ["landlord","tenant","address","month","year","rent"];
function save(){ const o={}; for(const f of FIELDS) o[f]=$(f).value;
  localStorage.setItem("rentreceipt.v1", JSON.stringify(o)); }
function load(){ const o=JSON.parse(localStorage.getItem("rentreceipt.v1")||"{}");
  for(const f of FIELDS) if(o[f]!==undefined) $(f).value=o[f]; }
Enter fullscreen mode Exit fullscreen mode

Next month the landlord clicks "Next month": the period and receipt number increment, everything else stays. A bug I shipped: I set the default month from new Date() after calling load(), so a restored empty value won the race and every receipt defaulted to January. Set defaults first, then restore.

3. Amount in words, without a library.

A receipt should say "$1,200.00 (one thousand two hundred dollars)". A number-to-words function is about forty lines for English, and the cents are the tricky part (round first, split, then format). I kept it per-currency rather than generic because "euros and cents" and "pounds and pence" need their own words anyway.

Where the single-file approach breaks

  • No email delivery. The user attaches the PDF themselves.
  • No multi-device sync. The data lives in one browser profile; clearing site data erases it. I say so on the page.
  • No updates in place. If I fix a bug, people who downloaded v1.0 keep v1.0. The free version is hosted so it always gets the fix; the paid files are versioned zips.
  • Mobile Safari prints oddly: print preview works, but @page margins are partially ignored.

Result

The free version is hosted here: https://paperworkkit.netlify.app/en/ (and as a browser-playable tool on itch.io: https://paperworkkit.itch.io/rent-receipt-free). The same engine runs five more documents a small landlord needs (late notice, deposit return, rent increase, inspection checklist, annual statement), sold as a kit of offline files: https://paperworkkit.itch.io/landlord-paperwork-kit

If you have built single-file tools and solved the mobile print problem, I would like to hear how.

Top comments (0)