Modeling Invoice Corrections as New Documents, Not Edits
I built a TypeScript/Express REST API for managing invoices through a controlled lifecycle — drafts can be edited, finalized invoices can be issued and exported as PDFs, and corrections are handled as replacement invoices rather than by mutating the original. Here's how the lifecycle works, and two real bugs I only caught by looking at the actual output.
Why an issued invoice can't just be "edited"
The core design decision in this project: once an invoice is issued, it becomes immutable. You can't PATCH its line items, its customer name, or its due date anymore. This mirrors how real invoicing and e-invoicing systems work — once an invoice is sent, its amounts are a fixed accounting record. If a mistake is found afterward, you don't rewrite history. You issue a correction.
The lifecycle has four states:
draft ──issue──────► issued
draft ──cancel─────► canceled
issued ──cancel────► canceled
issued ──replace───► replaced (and a new invoice is created as `issued`,
pointing back at this one)
A draft is editable and deletable, and can even have zero line items while it's being prepared. An issued invoice requires a customer name, at least one valid line item, and a due date — and from there it's a one-way door to either canceled or replaced.
The replace pattern, and why it needs a transaction
When a correction is needed, the API creates a brand-new, independently numbered invoice — issued immediately — linked back to the original via replacesInvoiceId. The original invoice is simultaneously marked replaced and linked forward via replacedByInvoiceId.
This needs two distinct Prisma relation names, because both foreign keys point back to the same Invoice model. It's an easy detail to get backwards the first time — I did, briefly — because Prisma doesn't complain loudly about ambiguous self-relations until you try to actually query both directions.
Both writes — creating the new invoice and updating the original — plus their status-history log rows, are committed inside a single Prisma $transaction. That's not a nice-to-have. Without it, a crash between step one and step two would leave the system in a state where invoice A claims it was replaced by invoice B, but invoice B doesn't exist yet — or worse, exists but doesn't know it's the replacement. A transaction makes that inconsistent state structurally impossible rather than just unlikely.
Two bugs that automated tests didn't catch
The test suite covers the domain state machine, validation, draft-only CRUD rules, lifecycle transitions, the transactional replace orchestration, and PDF generation — 56 tests, 97.95% domain coverage, 98.27% service coverage. And it still missed two real bugs.
Bug one: dividing by 100. The first PDF implementation divided every stored amount by 100 before rendering it — which is the correct move if you're storing USD in cents. This project defaults to VND, which has no practical minor subdivision. A 150000-cent value was rendering as VND 1,500.00 on the PDF instead of the correct VND 150,000. The unit test for this didn't catch it, because the test's expected value had been written with the exact same wrong assumption baked in. The test and the code were wrong in the same direction, so they agreed with each other perfectly. I only found it by generating a real invoice and reading the number with my own eyes.
That's the actual lesson here, more than the specific bug: automated tests validate that your code is consistent with what you expect — not that your expectations are correct. If the bug is in your assumptions rather than your logic, the test you write to catch it will often just encode the same mistake.
Bug two: text that wraps when you're not looking. The right-aligned totals lines — Subtotal, Tax, Total — were wrapping onto two lines for larger amounts, because they weren't given an explicit text width in PDFKit. The test suite used pdf-parse to extract text and check its content, which is exactly why it didn't catch this: extracted text reflects what the words say, not how they're laid out on the page. A wrapped line and an unwrapped line extract to the same string. Fixing it just meant giving the totals block a fixed position and width — but finding it required actually opening the generated PDF and looking at it.
Design choices that held up
A few decisions I'd make again without hesitation:
- Integer money, always. Amounts are stored and calculated as integers, never floats, specifically to avoid floating-point rounding drift on currency math. The presentation layer is where currency-specific formatting belongs — not the storage layer.
-
A database sequence for invoice numbers, formatted as
INV-YYYY-NNNN, assigned at creation time (even for drafts). A sequence is atomic under concurrent requests in a way that a naivecount() + 1query never is — two requests hitting that at the same millisecond will not collide. - PDFKit over Puppeteer. No headless browser dependency, no Chromium binary that might fail to launch in a constrained CI environment, faster tests. The tradeoff is a lower-level layout API — which is exactly what caused bug two above — but for a document with a fixed, simple layout, that tradeoff was worth it.
- A layered architecture where the domain rules have zero dependency on Express or Prisma, and are fully unit-testable without touching a database at all. Repositories are the only layer that talk to Prisma; services combine domain rules with repository calls; routes stay thin.
The takeaway
If you're building anything that touches accounting data — invoices, ledgers, anything where "the record changed" needs its own record — model corrections as new, linked documents instead of in-place edits. It costs you a bit more schema complexity upfront (two relation names instead of one, a transaction instead of a single write) but it buys you an audit trail that can't silently drift, and it matches how the real-world process actually works. And whatever your test coverage percentage says, generate the real output and look at it — some bugs only exist in the gap between what your code returns and what a human sees.
Top comments (0)