How to Generate Invoices Programmatically (and What to Watch Out For)

A practical guide to generating invoices in code: data modeling, PDF rendering, numbering, taxes, and the edge cases that bite you in production.

pdfinvoicesnode.jsbillingbackend

How to Generate Invoices Programmatically (and What to Watch Out For)

Invoice generation looks trivial until you ship it. Then you discover that "render a PDF" is the easy part — and that legal numbering, tax rounding, currency formatting, and reissuing documents are where the real work hides. This post walks through a pragmatic approach for backend and full-stack developers, with the gotchas that tend to surface only after your first audit or customer complaint.

Start with the data model, not the PDF

The biggest mistake teams make is treating an invoice as a document. It isn't. An invoice is an immutable financial record that happens to have a PDF representation. Model it that way:

ts
type Invoice = { id: string; // internal UUID number: string; // human-facing, sequential, immutable issuedAt: string; // ISO timestamp, immutable once issued currency: string; // ISO 4217, e.g. "EUR" seller: Party; buyer: Party; lineItems: LineItem[]; subtotal: number; // in minor units (cents) taxBreakdown: TaxLine[]; total: number; status: "draft" | "issued" | "paid" | "void"; }; type LineItem = { description: string; quantity: number; unitPrice: number; // minor units taxRate: number; // e.g. 0.19 };

Two rules to live by:

  1. Store amounts in minor units (cents) as integers. Floats will ruin your day.
  2. Once an invoice is issued, it is immutable. Corrections are done via credit notes or a new invoice, never by editing the old one.

Numbering: the part everyone gets wrong

Most tax jurisdictions require invoice numbers to be sequential, gapless, and unique per seller (sometimes per series and per year). That has real consequences:

  • You cannot use a UUID as the visible invoice number.
  • You cannot generate the number in parallel without coordination.
  • You cannot "skip" a number because a request failed mid-flight.

A safe pattern is to allocate the number inside the same database transaction that finalizes the invoice:

ts
await db.transaction(async (tx) => { const row = await tx.one( `UPDATE invoice_counters SET value = value + 1 WHERE series = $1 AND year = $2 RETURNING value`, ["A", 2025] ); const number = `A-2025-${String(row.value).padStart(5, "0")}`; await tx.none( `INSERT INTO invoices (id, number, status, ...) VALUES (...)`, [/* ... */] ); });

If PDF generation fails afterwards, you regenerate the PDF — you do not roll back the number.

Taxes and rounding

Rounding is where finance teams catch bugs. Pick a strategy and apply it consistently:

  • Round per line item, then sum, or
  • Sum exact values, then round the total.

These produce different results. Most ERPs round per line. Document your choice and write tests against known cases:

ts
function roundHalfUp(cents: number): number { return Math.floor(cents + 0.5); } function lineTotal(item: LineItem): number { const gross = item.quantity * item.unitPrice; const tax = roundHalfUp(gross * item.taxRate); return gross + tax; }

For multi-rate VAT invoices, group line items by tax rate and emit a breakdown table. Auditors expect it.

Rendering the PDF

You have three realistic options:

  1. HTML + headless Chromium (Puppeteer / Playwright). Flexible, but you own a browser in production.
  2. A PDF library (pdf-lib, PDFKit, ReportLab). Fast, but layout is painful.
  3. A managed PDF API. You send data, you get a PDF.

If you go the API route, the workflow looks like this with Kamy:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { number: invoice.number, issuedAt: invoice.issuedAt, seller: invoice.seller, buyer: invoice.buyer, lineItems: invoice.lineItems, totals: { subtotal: invoice.subtotal, tax: invoice.taxBreakdown, total: invoice.total, }, }, }); await s3.putObject({ Bucket: "invoices", Key: `${invoice.id}.pdf`, Body: await fetch(pdf.url).then((r) => r.arrayBuffer()), });

Whichever option you pick, store the rendered PDF. Don't re-render on every download. A re-render six months later with an updated template would produce a different file for the same legal invoice, which is exactly what you want to avoid.

Things that bite you in production

A short list, in rough order of how often they show up:

  • Time zones. issuedAt should be a UTC timestamp, but the displayed date must match the seller's local jurisdiction. An invoice issued at 23:30 UTC may legally belong to the next day.
  • Currency formatting. Intl.NumberFormat is your friend, but locale and currency are independent — a German buyer may want USD formatted in German style.
  • Long line item lists. Test with 100+ items. Page breaks, repeated headers, and totals on the last page only.
  • Unicode in addresses. Right-to-left scripts, CJK characters, and combining diacritics. Make sure your fonts cover them.
  • Reissues and corrections. Build a credit-note flow from day one. Trying to