How to Generate Invoices Programmatically and What to Watch Out For

A practical guide to generating invoices in code: from picking a rendering strategy to handling taxes, locales, and the edge cases that bite you in production.

pdfinvoicesnode.jsbillingautomation

How to Generate Invoices Programmatically and What to Watch Out For

Generating invoices sounds simple until you actually ship it. You start with a basic HTML template and puppeteer.pdf(), and three months later you're debugging why a customer in Germany sees a comma as a thousands separator, why your VAT line is rounded wrong, and why the PDF you generated last March no longer matches what's in your database.

This post walks through the practical decisions and traps when generating invoices in code.

Pick your rendering strategy

There are three common approaches:

  1. HTML to PDF — Build an HTML template, render it with a headless browser. Good styling, easy to iterate.
  2. Direct PDF construction — Use a library like pdf-lib or PDFKit to draw on a canvas. Pixel-perfect, but tedious.
  3. A document API — Offload the rendering to a service (Kamy, for example). You ship a template once, then call an API with data.

For most products, HTML-to-PDF is the right default. It maps to skills your team already has. The tradeoff is that running Chromium in production is heavier than people expect: memory spikes, zombie processes, font loading races, and cold-start costs on serverless platforms.

A minimal local approach looks like this:

ts
import puppeteer from "puppeteer"; export async function renderInvoice(html: string): Promise<Buffer> { const browser = await puppeteer.launch({ args: ["--no-sandbox"] }); const page = await browser.newPage(); await page.setContent(html, { waitUntil: "networkidle0" }); const pdf = await page.pdf({ format: "A4", printBackground: true }); await browser.close(); return pdf; }

This works. It also breaks the moment you deploy it to Lambda without the right Chromium layer, or when a customer uploads a logo from a URL that takes 8 seconds to respond.

If you don't want to babysit headless browsers, an API call is shorter:

ts
import { kamy } from "@kamydev/sdk"; const { url } = await kamy.documents.render({ template: "invoice-v2", data: invoice, });

Either way, the harder problems live outside the rendering layer.

Treat invoices as immutable records

This is the single most common mistake: developers regenerate the invoice PDF every time it's downloaded, pulling current data from the database.

That's wrong. An invoice is a legal record of what was billed at a specific moment. If the customer changes their address, the issued invoice should not retroactively update.

Store a snapshot:

ts
await db.invoices.insert({ id: invoiceId, number: "INV-2024-00421", issuedAt: new Date(), customerSnapshot: { ...customer }, lineItemsSnapshot: [...items], totalsSnapshot: totals, pdfUrl: uploadedUrl, });

Render the PDF from the snapshot, store it (S3, R2, whatever), and serve the stored file. Never re-render unless you also bump the invoice version.

Money is not a number

Floating point and money do not mix. 0.1 + 0.2 !== 0.3 will eventually print on a real invoice and a real customer will email you about it.

Store amounts as integers in the smallest currency unit (cents, pence, øre). Do all math in integers, format only at the render boundary.

ts
function calcLineTotal(unitPriceCents: number, qty: number, taxBps: number) { const subtotal = unitPriceCents * qty; const tax = Math.round((subtotal * taxBps) / 10_000); return { subtotal, tax, total: subtotal + tax }; }

taxBps is basis points (1% = 100 bps). This avoids 0.19 style floats entirely.

Invoice numbering is harder than it looks

Most tax authorities require invoice numbers to be sequential, gapless, and unique. That means:

  • You cannot use uuid().
  • You cannot use MAX(number) + 1 without a lock — concurrent inserts will collide.
  • You cannot "delete" an invoice. Issue a credit note instead.

A safe pattern in Postgres:

sql
CREATE TABLE invoice_counters ( series text PRIMARY KEY, next_value bigint NOT NULL ); UPDATE invoice_counters SET next_value = next_value + 1 WHERE series = '2024' RETURNING next_value - 1 AS issued_number;

The UPDATE ... RETURNING is atomic. Wrap the invoice insert and counter increment in the same transaction.

Localization will surprise you

Invoices need locale-aware formatting for:

  • Currency (€1.234,56 vs $1,234.56)
  • Dates (2024-03-14 vs 14/03/2024)
  • Tax labels (VAT, GST, IVA, MwSt)
  • Address layout (postcode position varies)

Use Intl rather than string-concatenating:

ts
const fmt = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR", }).format(1234.56); // "1.234,56 €"

Also: store the locale and currency on the invoice snapshot. Don't read it from the customer record at render time.

Fonts, emojis, and non-Latin characters

If you render to PDF and the font doesn't include the glyph, you get blank boxes — sometimes silently. Test your template with:

  • Cyrillic, Greek, CJK names
  • Arabic addresses (RTL)
  • Long company names that wrap

Embed a font that covers the scripts you support (Noto Sans is a safe default) and