← Complete Projects

阅读中文版本

Build a PDF Invoice Generator

A complete PDF invoice generator: data model, computed totals, embedded fonts, PDF output, and tests that assert amounts and page count.

intermediate~4 hpdfinvoicingnodemediadocuments

Project goal

Render a valid, printable PDF invoice from structured data with correct totals, tax, and line items, and fail loudly on inconsistent input.

Final features

  • Invoice model
  • Integer totals
  • Layout
  • Line-item table
  • Embed font

Architecture

An invoice model validates line items and computes subtotal, tax, and total in integer minor units. A layout module positions header, table, and footer, and a PDF writer emits the document with an embedded font and a content stream.

Prerequisites

  • Node.js 20 LTS or later
  • A monospace or embedded font file
  • Basic understanding of PDF structure

Dependencies

  • pdf-lib ^1.17.1 — Create and embed PDF documents

Directory structure

pdf-invoice-generator/
├── src/model.ts
├── src/totals.ts
├── src/layout.ts
├── src/render.ts
├── tests/invoice.test.ts
└── package.json

Phases

1. Invoice model

Validate required fields and line items.

if (!items.length) throw new Error('invoice needs at least one line item');

Expected behavior: A malformed invoice must fail before it is rendered.

2. Integer totals

Compute amounts in minor units and format at the edge.

const subtotal = items.reduce((s, i) => s + i.unitCents * i.qty, 0);
const tax = Math.round(subtotal * rateBps / 10000);

Expected behavior: Floating point arithmetic silently loses cents.

3. Layout

Position header, table rows, and totals block with a page size.

const page = doc.addPage([595, 842]); // A4
page.drawText(invoice.number, { x: 50, y: 800 });

Expected behavior: Deterministic layout keeps long invoices readable.

4. Line-item table

Render rows and paginate when the table exceeds the page.

if (y < 80) { y = startNewPage(doc); }

Expected behavior: Invoices can have more rows than fit on one page.

5. Embed font

Register a Unicode font so names render correctly.

const font = await doc.embedFont(await fs.readFile('font.ttf'));

Expected behavior: Standard fonts cannot show every character.

Testing

Render a known invoice and assert the page count, that the totals text matches the computed cents, and that a zero-line invoice throws.

Troubleshooting

Totals differ by a cent

Amounts were computed in floating point; use integer minor units throughout.

Unicode names render as boxes

A standard font was used; embed a font that covers the characters.

Deployment

Generate invoices as immutable artifacts keyed by invoice number and store them in object storage. Keep the generation code and fonts versioned so reprints match.

Known limitations

  • No digital signature or e-invoicing format.
  • Latin and common scripts only.
  • No multi-currency conversion.

Project overview

An invoice is a document with a legal shape, so a generator that rounds in floating point or silently drops a line item is worse than no generator at all. This build computes money in integer minor units and refuses to render inconsistent input.

You will validate the invoice model, compute subtotal, tax, and total in whole cents, lay out a header, a line-item table, and a footer, and write a PDF with an embedded font and a content stream.

Integer arithmetic is not a stylistic choice; it is the difference between an invoice that reconciles against the ledger and one that is off by a cent on a tax line. Money is stored and summed in minor units, and formatting happens only at render time.

Failing loudly on inconsistent input is the other half: a zero-line invoice or a line whose amount does not match its quantity and unit price is a bug in the caller, and rendering it produces a plausible-looking document that is wrong.

Each phase adds one property: the model, integer totals, layout, the table, and the embedded font, so the generator is correct at every stage.

It is aimed at teams that generate invoices from application data and have discovered that a rendering bug becomes a billing dispute. The generator separates validation, arithmetic, and drawing, so a dispute can be traced to one of the three rather than to a mysterious PDF.

It is also a reminder that a document is an interface: once an invoice leaves the system it may be the only record a customer and an auditor compare against, so its correctness outranks any convenience in how it is produced.

What you will build

The generator provides five capabilities, each with a test that inspects the rendered document:

  • Invoice model
  • Integer totals
  • Layout
  • Line-item table
  • Embed font

An invoice model validates line items; integer arithmetic computes exact totals; a layout module positions the sections; a table renders the line items; and an embedded font makes the output reproducible everywhere.

The design treats the PDF as a rendering of validated data rather than as a place to compute, so any arithmetic that appears in the layout is provably the same arithmetic the model already performed.

Architecture and data model

An invoice model validates line items and computes subtotal, tax, and total in integer minor units. A layout module positions header, table, and footer, and a PDF writer emits the document with an embedded font and a content stream.

An invoice model validates the line items and computes the subtotal, the tax, and the total in integer minor units, so every amount in the document traces back to a validation step rather than to a formatting call.

A layout module positions the header, the table, and the footer at fixed coordinates, keeping pagination decisions out of the arithmetic and the arithmetic out of the drawing code.

A PDF writer emits a content stream plus an embedded font, so the document renders identically on a machine that lacks the font the author used.

Rounding rules are applied once at the point the tax is computed, so the total is the sum of the rounded parts rather than a rounded sum of unrounded parts.

The model is the authority on every number in the document; the layout reads computed fields rather than recomputing them. That discipline is what makes it possible to test the arithmetic without rendering anything, and to test the layout without trusting the arithmetic.

Tax and discount rules are applied in a defined order and recorded on the invoice, so the visible breakdown matches the model rather than being derived again at render time.

Prerequisites and dependencies

You need a PDF library with font embedding and a clear definition of the tax and rounding rules the invoice must follow.

  • Node.js 20 LTS or later
  • A monospace or embedded font file
  • Basic understanding of PDF structure

The dependencies cover the model, the layout, and the PDF writing, with no external service in the render path:

  • pdf-lib (^1.17.1) — Create and embed PDF documents
pnpm init
pnpm add pdf-lib
pnpm add -D typescript tsx vitest

Pin the font file and the tax rules in the repository, because a font substitution or a rounding change alters every reprint and makes an old invoice impossible to reproduce exactly.

Directory structure

Separate the invoice model, the arithmetic, the layout, the table rendering, and the font embedding so each can be tested against a fixed invoice:

pdf-invoice-generator/
├── src/model.ts
├── src/totals.ts
├── src/layout.ts
├── src/render.ts
├── tests/invoice.test.ts
└── package.json

Phase 1: Invoice model

Validate required fields and line items.

if (!items.length) throw new Error('invoice needs at least one line item');

A malformed invoice must fail before it is rendered.

Validate the invoice model first, rejecting a document with no line items and a line whose amount disagrees with its quantity and unit price, because a plausible-looking wrong invoice is the worst possible output.

Phase 2: Integer totals

Compute amounts in minor units and format at the edge.

const subtotal = items.reduce((s, i) => s + i.unitCents * i.qty, 0);
const tax = Math.round(subtotal * rateBps / 10000);

Floating point arithmetic silently loses cents.

Compute every total in integer minor units, and round only at the defined step, so the subtotal plus tax equals the total exactly rather than approximately.

Phase 3: Layout

Position header, table rows, and totals block with a page size.

const page = doc.addPage([595, 842]); // A4
page.drawText(invoice.number, { x: 50, y: 800 });

Deterministic layout keeps long invoices readable.

Lay out the header, the table, and the footer at defined positions, keeping drawing coordinates separate from the money arithmetic so a layout change cannot alter a number.

Phase 4: Line-item table

Render rows and paginate when the table exceeds the page.

if (y < 80) { y = startNewPage(doc); }

Invoices can have more rows than fit on one page.

Render the line items as a table with columns for description, quantity, unit price, and amount, and assert that the column values come from the computed fields rather than from re-derived strings.

Phase 5: Embed font

Register a Unicode font so names render correctly.

const font = await doc.embedFont(await fs.readFile('font.ttf'));

Standard fonts cannot show every character.

Embed the font so the document looks the same everywhere, and pin its version so a reprint years later matches the original byte for byte.

Testing

Render a known invoice and assert the page count, that the totals text matches the computed cents, and that a zero-line invoice throws.

Render a known invoice and assert the page count and the totals text against the computed cents, which ties the visible document back to the validated model.

Assert that a zero-line invoice throws rather than rendering an empty document, because an empty invoice that looks valid is the failure that reaches a customer.

Assert that a line whose amount disagrees with its quantity and unit price is rejected, which catches a caller that computed the amount separately and got it wrong.

Assert that the sum of the line amounts equals the subtotal and that the subtotal plus tax equals the total, so the arithmetic is verified as a set of identities rather than as a single example.

Since the document is the deliverable, at least one test should extract the text from the rendered PDF and compare it with the computed values, because a model that computes correctly while a layout prints the wrong field is a real and common failure.

Assert too that rejecting a zero-line invoice and a mismatched line happens before any rendering work, so an invalid document cannot leave a half-written artifact behind.

Assert the page count for a long invoice as well as a short one, because a pagination bug only appears when the line-item table overflows the page.

pnpm vitest run

Deployment and operations

Generate invoices as immutable artifacts keyed by invoice number and store them in object storage. Keep the generation code and fonts versioned so reprints match.

Store invoices as immutable artifacts keyed by invoice number, so a reprint fetches the stored document rather than regenerating it and risking a drift in the layout code.

Version the generation code and the fonts together, because a reprint must match the original and an unversioned change silently alters historical documents.

Keep the generation path free of external calls, since an invoice that cannot be generated during a provider outage is a billing problem rather than a display problem.

Monitor render failures, because a rejected inconsistent invoice is a caller bug that should be fixed rather than retried blindly.

Keep the generated artifact together with a record of the code and font versions that produced it, so an audit years later can reconstruct the document without guessing which release generated it.

Security considerations

Escape any user text drawn on the page and never embed remote content or external links blindly. Store invoices privately; they contain personal data.

Never compute money from caller-supplied strings if the caller can supply a preformatted amount, because the generator must be the authority on the arithmetic.

Treat the invoice content as sensitive and keep generated artifacts private, since an invoice carries names, addresses, and amounts that belong to a specific party.

Reject a document whose line amounts do not reconcile rather than rounding to make them fit, because a silent correction is indistinguishable from fraud.

Keep the font and the PDF writer pinned and reviewed, since a rendering library change can alter a document in ways a diff will not show.

Store generated invoices under access controls tied to the paying party, because an invoice enumerates what a customer bought and is more sensitive than a generic order record.

Bind the tax rules and the currency to the invoice model rather than to the caller, because an invoice whose tax treatment is supplied by the caller is indistinguishable from one that was tampered with.

Observability

Track generation time, failure rate, and page counts. Alert on failures, which usually indicate inconsistent input or a missing font.

Track render duration and page count, because a sudden change in page count for a stable invoice usually means a layout regression rather than a data change.

Log rejections with the reason, since a cluster of a single reason points at a caller that needs fixing rather than at the generator.

Measure generation errors separately from storage errors, because they have different owners and different fixes.

Alert on a rise in zero-line rejections, because that usually indicates a caller building an invoice from an empty result set.

Track the distribution of rejection reasons, because a spike in one reason points at a single caller building an invalid document rather than at the generator itself.

Track the fraction of invoices served from storage rather than regenerated, because a high regeneration rate means the storage retention is too short for the reprint requirement.

Troubleshooting

Totals differ by a cent. Amounts were computed in floating point; use integer minor units throughout.

Unicode names render as boxes. A standard font was used; embed a font that covers the characters.

If the total is off by a cent, check the rounding step first, because rounding each line and then summing gives a different answer from summing and then rounding.

If a reprint differs from the original, check the font version and the layout version, since either change alters the output even when the data is identical.

If a printed amount differs from the model, check the layout field mapping, because a table bound to the wrong column produces a document that looks correct and is not; if an old reprint fails, check whether the artifact was stored or regenerated.

Limitations and next steps

  • No digital signature or e-invoicing format.
  • Latin and common scripts only.
  • No multi-currency conversion.

The generator produces a fixed invoice layout and does not support arbitrary templates or branding, which keeps the output predictable but limits customization.

Tax is computed with a single rule per invoice, so a multi-jurisdiction tax breakdown would need a more detailed model.

There is no digital signature or stamping, so authenticity relies on the storage access controls rather than on a cryptographic seal.

Localization is limited to number and currency formatting, not to translated labels, which a larger system would centralize.

The generator does not produce a machine-readable invoice alongside the PDF, so an e-invoicing format would require a separate serialization of the same validated model.

There is no multi-currency invoice that converts between currencies, so a cross-border invoice would need the conversion recorded as its own line with its own rate.