# 0016 — Integer money, stored totals, and corrections that never edit

**Status:** Accepted · 2026-08-02

## Context

Billing is the one module where a defect is not discovered by the person using
the software. A wrong appointment time is noticed within the hour. A rounding
error of one minor unit on one line in a hundred thousand is discovered by an
auditor, years later, in a filed return — and by then it is in every invoice the
clinic has issued since.

Three decisions have to be made before any of it is written, and all three are
expensive to reverse once real invoices exist.

## Decision

### 1. Every amount is an integer, and every rate is a ratio of integers

Money is a `BIGINT` of minor units plus its currency code. No `FLOAT`, no
`DOUBLE`, no `DECIMAL` that arithmetic can widen. `App\Support\Money\Money` is
the only place arithmetic on those integers happens.

Rates go further. A tax rate is stored in **basis points** (15% is `1500`), a
percentage discount in **hundredths of a percent**, a quantity in **thousandths**
— and all three are applied through one new primitive:

```php
$base->multiplyRatio($numerator, $denominator);   // integer throughout
```

This exists because `multiply(0.15)` is *nearly* always right. The error is
around 1e-16 and invisible until it lands on a value ending in exactly half a
minor unit and tips the rounding the wrong way. `multiplyRatio` cannot do that,
at the cost of one extra method.

Rounding is **half away from zero**, once per line, which is what invoice lines
and tax tables expect.

### 2. Totals are computed on write and stored, never derived on read

Every figure on an invoice — subtotal, discount, tax, total, paid, credited,
balance — is a column. All of them *could* be derived from the lines.

They are not, because tax rates change, catalogue prices change, and the
rounding rule itself may change in a future release. An invoice is a statement
of what was owed on a given day and it has to keep saying that. The same
principle puts the drug name on a prescription line and the price, tax name and
rate on an invoice line: **a document copies what it needs; it never renders
itself by joining to a mutable table.**

The order of operations is fixed and tested:

1. line subtotal = unit price × quantity
2. line discount
3. invoice-level discount, allocated across eligible lines
4. taxable base = subtotal − both discounts
5. tax, per rate, on that base

Discount **before** tax is not stylistic. Taxing the undiscounted amount charges
the patient tax on money they did not pay, which is illegal in most
jurisdictions and wrong in all of them.

The invoice-level discount is **allocated** across lines by largest remainder
rather than applied to the total, because tax is per line and lines may carry
different rates. Allocation guarantees the parts sum exactly to the discount
given — the property that makes the invoice foot to the minor unit, and the
reason `Money::allocate` exists at all. Lines flagged non-discountable are
excluded from both the base and the allocation, so a regulated fee is not
quietly reduced by a bill-level discount.

Tax is multi-rate capable in the schema (`invoice_item_taxes`, one row per rate)
even though the shipped UI applies one. Adding a second simultaneous tax later
would otherwise mean rewriting stored totals on historical invoices, and stored
totals are exactly what must never be rewritten.

Inclusive and exclusive rates are both supported, per rate, and the invoice
total is **the sum of the line totals** rather than `subtotal − discount + tax`
— because that formula is right for exclusive lines and wrong for inclusive
ones, and a clinic can legitimately have both.

### 3. Nothing financial is edited or deleted

| Situation | Correction | What survives |
| --- | --- | --- |
| Draft, before issue | edit freely | nothing yet exists |
| Issued, no money moved | **void** with a reason | the row and its number |
| Issued, money moved | **credit note** | both documents |
| Payment taken in error | **reversal** row, negative, pointing back | both rows |
| Payment correctly taken, owed back | **refund** row, negative | both rows |

Enforced at the **model**, not only in the service: `Invoice::save()` throws if
what-was-charged changes on a non-draft, `Payment::save()` throws on any change
at all, and `delete()` throws on invoices, payments, credit notes and cash
sessions. The service is not the only thing that can reach a model — an
importer, a console command, a future API controller and a well-meaning refactor
all go through `save()`.

A voided invoice **keeps its number**. A gap in an invoice sequence is precisely
what an auditor asks about, and "voided, here is the row and the reason" is the
answer that satisfies them.

Payments, reversals and refunds live in **one table with a signed amount**, so
every total in the module is a sum over a single set of rows. Two tables —
payments and refunds — is the arrangement where somebody sums one and forgets
the other.

## Consequences

**Good.** The invoice foots. This is tested directly: seventeen awkward lines
with fractional quantities, a percentage bill discount and 15% tax, asserting
that the sum of the stored lines equals the stored total to the minor unit.

**Good.** History is readable. Every document says what it said on the day it
was issued, whatever has happened to the price list since.

**Good.** A correction is visibly a correction. The pair of documents reads as a
history rather than as an edit, which protects the person who made an honest
mistake as much as it protects the clinic.

**Bad.** More rows. A corrected invoice is two documents, a reversed payment is
two rows, and nothing ever shrinks. At clinic volumes this is nothing; the
alternative loses information that cannot be recovered at any price.

**Bad.** More writing. Every mutation of a draft recalculates and rewrites every
line and every tax row. This is deliberate — the alternative is figures that
disagree with each other depending on which one was written last — but it means
a draft with fifty lines does fifty updates on each edit. Drafts do not have
fifty lines.

**Honest limit.** `saveQuietly()`, `forceFill()` and direct database access still
reach the rows. These are guardrails against ordinary mistakes, not a claim of
tamper-proofing; the hash-chained audit trail
([ADR 0005](0005-tamper-evident-audit-trail.md)) is what covers the rest.

**Honest limit.** There is no patient account ledger, so an overpayment is
**refused** rather than held as a credit. This is the right answer for a clinic
taking payment at a desk and the wrong one for a clinic running accounts;
adding the ledger later is a new table and a new balance, not a change to any of
the above.

## Related

The cash till is the same principle applied to a shift: the expected figure is
derived from the payment rows, the counted figure is entered independently, and
the variance between them is stored exactly as it falls. **Nothing in the
service adjusts a session to make it balance** — a drawer that is four short is
a fact about that shift, and software that quietly corrects it has destroyed the
only signal that something went wrong.
