Skip to content
~sami-ullah

Concept project

Invoice and PDF Generator API

A concept project with a working demo: an invoice API design that calculates money in integer minor units, numbers invoices without gaps, enforces a draft to sent to paid status flow and makes POST /invoices safe to retry with idempotency keys. The demo renders a printable A4 invoice you can save as PDF.

Screenshot of the Invoice and PDF Generator API demo

Overview#

Invoices look like simple CRUD until the numbers have to be right. A total that is one cent off, a number that appears twice, or a duplicate invoice created because a client retried a request are all real problems for a small business, and all of them are backend problems.

This concept is the invoicing API I would build in NestJS. The live demo runs the API logic in your browser: an invoice builder with a live A4 preview, an API console, the database tables and a request log.

My role#

Full build: money model, totals algorithm, numbering, status rules, idempotency handling, the print layout, the simulator and this write-up. It is a concept project. The seller, customers and invoices are fictional.

The problem#

  • Floating point cannot hold money. 0.1 * 3 in JavaScript is 0.30000000000000004. Summing, discounting and taxing with floats produces totals that differ from what an accountant calculates by hand.
  • Rounding must follow a rule. Tax rounded per line and tax rounded on the total give different answers. The system has to pick one and apply it the same way everywhere.
  • Invoice numbers must be sequential with no gaps, which many tax authorities expect. Two parallel requests must never get the same number.
  • Networks fail. A client that times out on POST /invoices and retries must not create a second invoice.
  • Sent invoices are legal documents. They cannot be edited, only voided and reissued.

Approach & architecture#

◆ Create and finalise
  1. Clientthen
  2. POST /invoices + Idempotency-Keythen
  3. claim key (processing)then
  4. ValidationPipethen
  5. compute totalsthen
  6. INSERT draftthen
  7. store response
  1. Clientthen
  2. POST /invoices/:id/sendthen
  3. lock counter rowthen
  4. next numberthen
  5. status sent
  1. Clientthen
  2. GET /invoices/:id/pdfthen
  3. render A4then
  4. application/pdf

Decimal strings in, BigInt minor units inside. The API refuses JSON numbers for money (19.99 as a number is already a float) and accepts strings. Parsing goes straight to integers without passing through a float:

ts
export function parseDecimal(input: string, exp: number): bigint | null {
  const s = input.trim().replace(/,/g, "");
  const m = /^(\d{1,12})(?:\.(\d+))?$/.exec(s);
  if (!m) return null;
  const frac = m[2] ?? "";
  if (frac.length > exp) return null;
  return BigInt(m[1]!) * 10n ** BigInt(exp) + BigInt((frac + "0".repeat(exp)).slice(0, exp) || "0");
}

Each currency knows its number of decimals, so JPY amounts are whole yen and a price like "500.5" is refused for it.

One rounding rule, applied per line. Quantity is stored in thousandths, rates in hundredths of a percent. A line's gross is qty × price / 1000, rounded half-up once. An invoice-level discount is spread across the lines in proportion to their net amount with the largest-remainder method, so the shares add up exactly to the discount. Tax is then rounded per line and summed per rate for the tax summary.

Gap-free numbering at send time. Drafts have no number. Sending one runs, in the production design:

sql
INSERT INTO invoice_counters (year, last) VALUES (2026, 1)
ON CONFLICT (year) DO UPDATE SET last = invoice_counters.last + 1
RETURNING last;

The upsert takes a row lock, so concurrent sends queue and each gets the next number. Because numbers are only assigned on send, deleted drafts leave no holes, and voided invoices keep their number.

Idempotency keys. Before any work, the request claims its key together with a fingerprint of the body. The outcome decides the response:

SituationResponse
New keyProcess, store the 201 against the key
Same key, same body, finishedReplay the stored 201 with Idempotent-Replayed: true
Same key, still processing409 with Retry-After: 1
Same key, different body422
Validation failed400, and the key is released so the client can fix and retry

Key challenges#

  • Discount allocation that adds up. Splitting 1.00 across three equal lines gives 0.34, 0.33 and 0.33, not three times 0.33. The allocate function hands out the leftover cents to the lines with the largest remainders, deterministically.
  • The double-click. Two identical requests 50 ms apart both pass a "does this key exist?" check if it is done with a SELECT. Claiming the key with INSERT … ON CONFLICT DO NOTHING makes the database decide which request wins. The demo has a button that simulates exactly this.
  • A PDF that matches the screen. I wanted one layout. The preview is plain HTML and CSS with fixed print colours and an A4 @page rule; the print stylesheet hides everything else, so "Save as PDF" gives a clean single page with selectable text. In the production design the same layout is drawn with pdfkit on the server.
  • Status rules in one place. Each status endpoint lists the states it accepts. Paying a draft, voiding a paid invoice or editing a sent one all return 409 with a message that says what to do instead.

Tech stack#

  • Demo: TypeScript, React and Next.js. money.ts holds the arithmetic, api.ts the routes, validation, numbering and idempotency, and InvoicePreview.tsx the print layout. All three are under "View source" on the demo page.
  • Production design: NestJS on Node.js, PostgreSQL with bigint minor-unit columns and an invoice_counters table, class-validator DTOs, an idempotency interceptor backed by an idempotency_keys table, pdfkit for server-side PDFs, and Swagger docs generated from the DTOs.

Results#

  • A working invoice builder with five currencies, per-line and invoice-level discounts, multiple tax rates, a tax summary and a live A4 preview that prints to a one-page PDF.
  • Totals are exact to the minor unit and shown next to the same calculation in floats, so the difference is visible.
  • Idempotent creation covers replay, in-flight conflict, payload mismatch and key release after validation errors.
  • Numbering and the status flow behave as described, with 409 responses for invalid transitions.

This is a concept build without real users, and no invoices in it are real.

Try the demo#

Open the invoice API demo. Things to try:

  1. Change quantities and prices and watch the totals panel: the "SMS credits" line (3 × 0.10) is where floats start to drift.
  2. Press POST /invoices, then press it again with the same key. The second response is a replay, and the invoices table still has one new row.
  3. Press simulate double-click with a new key and read the log: one 409, one 201.
  4. Press send on the new draft, then Download PDF.

What I would do next#

  • Port the logic into Nest modules with Jest tests on the totals fixtures and a test that sends 50 invoices in parallel and checks the numbers.
  • Add credit notes that reference the original invoice instead of editing it.
  • Support tax-inclusive prices, which need a different rounding order.
Full screenshot of the Invoice and PDF Generator API demo running in the browser
Screenshot of the live demo with its sample data. Click to open it.
▶ live demoBuild an invoice, watch exact totals update, double-submit it to test idempotency, then save it as a PDF.

Skills used