How to Generate Invoices Programmatically and What to Watch Out For
Generating invoices sounds like a solved problem until you actually ship it. You start with a simple template, then compliance requirements creep in, VAT rules get weird, a customer complains that invoice #42 is missing, and suddenly your "quick weekend task" is a three-month project. This post walks through how to generate invoices programmatically and the pitfalls that tend to bite teams in production.
The anatomy of an invoice
An invoice is a legal document, not just a styled receipt. Most jurisdictions require at least:
- A unique, sequential invoice number
- Issue date (and often a due date)
- Seller details (legal name, address, tax ID)
- Buyer details
- Line items with quantities, unit prices, and totals
- Tax breakdown (rate, base, amount)
- Grand total and currency
A reasonable data model looks like this:
tstype Invoice = { number: string; // "INV-2025-000123" issuedAt: string; // ISO date dueAt: string; currency: "EUR" | "USD" | "GBP"; seller: Party; buyer: Party; lines: LineItem[]; taxSummary: TaxBucket[]; subtotal: number; // in minor units (cents) taxTotal: number; total: number; notes?: string; }; type LineItem = { description: string; quantity: number; unitPrice: number; // minor units taxRate: number; // 0.19 for 19% lineTotal: number; // minor units };
Store money in minor units as integers. Floats will burn you.
Invoice numbering: harder than it looks
Most tax authorities require invoice numbers to be sequential, gap-free, and immutable once issued. That rules out generating the number client-side or using a UUID.
The naive approach — SELECT MAX(number) + 1 — breaks under concurrent writes. Use a database sequence or a dedicated counter row with a transaction:
sqlBEGIN; UPDATE invoice_counters SET next_value = next_value + 1 WHERE year = 2025 RETURNING next_value - 1 AS assigned; -- insert the invoice using the returned value COMMIT;
Rules to live by:
- Never reuse a number, even for a cancelled invoice. Issue a credit note instead.
- Decide upfront whether numbering resets yearly (common in the EU) or is global.
- Reserve the number only when the invoice is actually finalized, not on draft creation.
Taxes, rounding, and currencies
Tax calculation is where "I'll figure it out later" becomes a bug tracker full of P1s.
Rules you want baked in from day one:
- Compute tax per line, then sum per rate, then sum totals. Rounding at the wrong step produces off-by-one-cent errors that will not match your accountant's spreadsheet.
- Use banker's rounding or the rounding mode mandated by local law.
- Keep currency explicit on every amount. Don't rely on an invoice-level default inside line items.
- For cross-border B2B in the EU, you may need to handle reverse charge and skip VAT entirely — but still show the VAT ID and a legal note.
A safe helper:
tsfunction roundMinor(amount: number): number { // amount is already in minor units (cents) but may be fractional // from quantity * unitPrice math return Math.round(amount); } function lineTotal(qty: number, unitPrice: number): number { return roundMinor(qty * unitPrice); }
Rendering the PDF
You have three realistic options:
- HTML + headless Chromium (Puppeteer/Playwright). Flexible, but you own the infrastructure, font loading, and memory leaks.
- A PDF library like
pdfkitorpdf-lib. Full control, lots of code for anything non-trivial. - A PDF API that takes a template plus JSON and returns a PDF.
If you don't want to maintain a Chromium farm, this is where something like Kamy fits in. You define a template once and call it with the invoice data:
tsimport { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice-v2", data: invoice, }); // pdf.url is a signed URL valid for one hour await storage.put(`invoices/${invoice.number}.pdf`, pdf.url);
Whatever you pick, make sure:
- Fonts are embedded so the PDF renders the same everywhere.
- Output is PDF/A if you need long-term archival (required in some jurisdictions).
- The file is deterministic — same input produces the same bytes. Helpful for deduplication and integrity checks.
Storage, delivery, and immutability
Once an invoice is issued, treat the PDF as immutable. Store it in object storage with versioning disabled on the key, and keep a hash in your database:
tsconst buffer = await fetch(pdf.url).then(r => r.arrayBuffer()); const sha256 = createHash("sha256").update(Buffer.from(buffer)).digest("hex"); await db.invoices.update(invoice.id, { pdfKey: `invoices/${invoice.number}.pdf`, pdfSha256: sha256, });
If a customer requests a change, don't regenerate the file. Issue a new document (credit note + corrected invoice). Your auditors will thank you.
Things that will go wrong in production
A short list, learned the hard way:
- Timezones. An invoice issued at 23:30 UTC on Dec 31 may