How to Generate Invoices Programmatically and What to Watch Out For
Generating invoices sounds like a solved problem — stuff some data into a template, output a PDF, email it. In practice, most teams get it wrong the first time. Invoice numbers collide, totals don't match line items by a cent, tax rules differ per country, and someone eventually asks why the regenerated PDF for an old invoice looks different from the original.
This post walks through what a solid programmatic invoice pipeline looks like, with a focus on the parts that tend to break.
The core pieces
A production invoice system has four distinct concerns, and conflating them is where most bugs come from:
- Invoice data model — the authoritative record in your database.
- Numbering — assigning stable, often legally-required identifiers.
- Rendering — producing a PDF that is pixel-stable over time.
- Delivery and storage — emailing, archiving, re-serving.
Treat each as its own problem.
Step 1: Model the invoice as immutable
An invoice is a legal document. Once issued, it must not change. If a customer disputes a charge, you issue a credit note or a new invoice — you do not edit the old one.
Store invoices as append-only records:
tstype Invoice = { id: string; // internal UUID number: string; // human/legal identifier, e.g. "2024-000142" issuedAt: string; // ISO timestamp, immutable once set currency: string; // ISO 4217 lineItems: LineItem[]; subtotal: number; // in minor units (cents) taxTotal: number; total: number; customerSnapshot: CustomerSnapshot; // frozen at issue time sellerSnapshot: SellerSnapshot; };
Note the customerSnapshot and sellerSnapshot. If the customer later updates their billing address, the old invoice must still show the old address. Never join to live customer data at render time.
Step 2: Get the numbering right
Invoice numbers must be:
- Sequential without gaps (required by law in many jurisdictions, e.g. France, Germany, Italy).
- Unique per issuing entity.
- Allocated atomically, even under concurrent requests.
A naive MAX(number) + 1 query will deadlock or produce duplicates under load. Use a dedicated counter row with row-level locking, or a Postgres sequence per entity:
sqlCREATE SEQUENCE invoice_number_2024; SELECT nextval('invoice_number_2024');
Only increment the counter after the invoice row is committed. If PDF generation fails later, that's fine — the invoice exists; retry the render. If you increment first and the transaction rolls back, you'll have gaps, which is exactly what regulators dislike.
Step 3: Do money math correctly
Use integer minor units (cents) everywhere. Never use floats for money. Compute per-line tax, then sum — don't compute tax on the subtotal, because rounding will drift:
tsfunction computeLine(line: LineItem) { const net = line.quantity * line.unitPriceCents; const tax = Math.round(net * line.taxRate); return { net, tax, gross: net + tax }; }
Then verify at the end that sum(lineGross) === total. If it doesn't, fail loudly before the PDF ever gets written. A one-cent mismatch on an invoice looks unprofessional and in some tax regimes is non-compliant.
Step 4: Render a stable PDF
This is where most teams underestimate the work. A PDF rendered from HTML needs:
- Deterministic fonts (embedded, not system fonts).
- Correct page breaks for long line-item tables.
- Proper handling of multi-page headers/footers and page numbers.
- Unicode support for customer names, addresses, and currencies.
You can build this with Puppeteer or wkhtmltopdf, but you'll own a headless browser in production, deal with font licensing, and spend a weekend figuring out why € renders as a box.
This is where a service like Kamy helps. You keep your invoice data in your DB, define a template once, and render:
tsimport { kamy } from "@kamydev/sdk"; const { url } = await kamy.documents.render({ template: "invoice-v1", data: invoice, idempotencyKey: invoice.id, });
The idempotencyKey matters: if your worker retries, you get the same PDF back instead of a duplicate render. Whatever tool you use, make sure retries are safe.
Step 5: Store the generated PDF, don't regenerate
Once an invoice PDF is produced, store the bytes. Do not regenerate on demand from the template, because:
- Template changes will silently alter old invoices.
- Font or library updates can shift layout.
- Tax rules change over time.
Upload the PDF to object storage (S3, GCS) keyed by invoice ID, and keep a checksum:
tsawait s3.putObject({ Bucket: "invoices", Key: `${invoice.id}.pdf`, Body: pdfBytes, ContentType: "application/pdf", Metadata: { sha256: hash }, });
When a customer requests a copy, serve the stored file. Never re-render.
Common pitfalls
A short checklist of things that will bite you:
- Timezones. Issue date should be stored as UTC but rendered in the seller's local timezone. A midnight-boundary bug can push an invoice into the wrong tax period.
- Reverse charge / VAT exemptions. B2B cross-border EU invoices often require a specific legal note. Model this per customer, not