# 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.

- Author: Sami Ullah (https://samiullah.tech/about)
- Type: Concept project
- Role: Full build
- Stack: TypeScript, React.js, Next.js
- Year: 2026
- Updated: 2026-11-13
- Canonical: https://samiullah.tech/work/invoice-pdf-api

## 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](/skills/nestjs). The [live demo](/lab/invoice-pdf-api) 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

```flow
# Create and finalise
Client -> POST /invoices + Idempotency-Key -> claim key (processing) -> ValidationPipe -> compute totals -> INSERT draft -> store response
Client -> POST /invoices/:id/send -> lock counter row -> next number -> status sent
Client -> GET /invoices/:id/pdf -> render A4 -> 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:

| Situation | Response |
| --- | --- |
| New key | Process, store the 201 against the key |
| Same key, same body, finished | Replay the stored 201 with `Idempotent-Replayed: true` |
| Same key, still processing | 409 with `Retry-After: 1` |
| Same key, different body | 422 |
| Validation failed | 400, 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](/skills/nestjs) on [Node.js](/skills/nodejs), [PostgreSQL](/skills/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](/lab/invoice-pdf-api). 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.
