flightpack/billing: money as integers and numbers that never repeat

Quotations, invoices, payments and credit notes on projects. The decisions that matter are three: integer sen, locked document numbers and one row lock against double billing.

flightpack/billing has 51 commits, and 26 of them landed in its first two days, 13 and 14 October 2026. By the end of 14 October it could issue a quotation, turn it into an invoice, record a payment, raise a credit note and produce PDF output. That is fast for money software. It is possible because three decisions were made before any screen existed.

Decision one: money is an integer

Amounts are stored as bigint in sen. A small Money class and a DocumentTotals class do every sum with integers only. Rounding is half-up away from zero, and an overflow is refused instead of wrapping. The server recomputes and stores totals itself, whatever the browser sent.

The browser needs the same arithmetic to show a total before saving, so there is a TypeScript twin. Two implementations of one rule are a standing invitation to drift, so both are tested against the same file of test vectors: one PHPUnit test and one Node test read it. If a vector changes, both fail together.

money-vectors.json
  -> PHPUnit MoneyVectorsTest        (server arithmetic)
  -> Node billing-money test          (browser arithmetic)
a mismatch fails whichever side is wrong

Decision two: a number is issued inside a transaction

A document number looks like QT-2026-0001. The counter lives in a sequence table keyed by type and year, and it is read with a row lock inside the same transaction that issues the document. A number is never reused. If the issue rolls back, the number goes back with it, so a failed attempt leaves no hole. A revision keeps the root number and adds a suffix such as -R2.

Gaps in invoice numbers are the first thing an accountant looks for. Taking the lock at issue time, and not at draft time, is what keeps the sequence honest.

Decision three: one row lock against double billing

An invoice draft can come from approved milestones, from an accepted quotation (the whole of it, or one payment step) or from nothing. Milestone money moves with the draft through one service call that runs under a row lock, so two drafts cannot bill the same money. Deleting the draft or voiding the invoice releases it. This is the rule I would test first in any billing system, and it is the one the README calls a business rule rather than a feature.

The quotation lifecycle

StateWhat changes
draftEditable. A stale save is refused with a conflict because the row carries a version
issuedLines, total, validity, discount approval and payment steps are checked; the layout is frozen; a lead project moves to proposal
acceptedThe project moves to planning
declined, expired, cancelledTerminal. A scheduled command expires quotations past their validity
supersededAn issued revision replaces it

The rest of the package, briefly

What it needs and how it leaves

It requires the projects package and uses clients and the library when they are present. billing:install checks its prerequisites, runs migrations, seeds permissions and is safe to run twice. billing:uninstall removes permissions and keeps the data; --drop-data drops the tables and refuses while a package that depends on billing is installed. The same contract applies to every package, as the modular design post describes.

What I would tell another small team

Settle three things before you draw a billing screen: what type holds money, where the next number comes from, and what stops one amount being billed twice. Everything after those is a form.

Retrospective post. Written on 27 October 2026 from our project records and dated to the day the work happened.
Net Advice
Loading