Money math in integer cents, and the discount that vanished
We gave a fifty-cent discount and the ledger recorded none of it. Floating point was not even the culprit. A field guide to doing money arithmetic with integers: clamping, proration, and making a ledger of unit prices sum to the exact total.
Every engineer learns early that 0.1 plus 0.2 is not 0.3, so we all store money as integer cents and feel safe. Then you build something real, like a percentage discount across a cart, and discover that integers only solve the first problem. The second problem is that division does not care about your ledger.
The fifty cents that went missing
Here is the setup that bit us, reduced to its bones. A cart holds 100 units of a one-dollar item. The customer gets fifty cents off the order. The subtotal is 10,000 cents, the discount is 50 cents, and the discounted total is 9,950 cents. So far, nothing but integers.
Now record that sale in a ledger that stores a unit price per line, the way accounting systems like to. The discounted unit price is 9,950 divided by 100, which is 99.5 cents. That is not an integer, so it gets rounded to 100. The ledger now says 100 units at 100 cents: a full-price sale. The discount did not shrink. It vanished, while the interface promised the customer 9,950 and the books said 10,000.
// The trap: one rounded unit price cannot represent every total.const subtotal = 100 * 100; // 10000cconst discounted = subtotal - 50; // 9950cconst unit = Math.round(discounted / 100); // 100c ... the discount is goneconsole.log(unit * 100); // 10000c, not 9950c // The fix: split the line into two prices one cent apart.const qty = 100;const base = Math.floor(discounted / qty); // 99cconst rem = discounted - base * qty; // 50 units get one extra cent// rem units at (base + 1), the rest at base:// 50 * 100 + 50 * 99 === 9950 ✓ exact, and every price is an integerThe split looks fussy until you see what it buys. Fifty units at 100 cents plus fifty units at 99 cents equals exactly 9,950. Every row still stores an honest integer unit price, the quantities still add up, and the sum of the ledger equals the number on the receipt to the cent. Nothing about that is possible with a single rounded price.
Proration needs a reconciliation pass
The same disease appears one level up when an order-level discount spreads across several lines. Give each line its proportional share and round, and the shares will sum to almost the discount. Sometimes one cent over, sometimes one under, occasionally spot on, which is the worst outcome of all because it hides the bug from casual testing.
// Prorate by share, then push the rounding leftover onto lines with room.let allocated = 0;for (const line of lines) { line.discount = Math.min( line.subtotal, Math.round((discount * line.subtotal) / orderSubtotal), ); allocated += line.discount;}let remainder = discount - allocated; // may be negativefor (const line of lines) { if (remainder === 0) break; const room = remainder > 0 ? line.subtotal - line.discount : line.discount; const move = Math.min(Math.abs(remainder), room) * Math.sign(remainder); line.discount += move; remainder -= move;}// invariant: sum(line.discount) === discount, and no line goes negativeTwo details in that loop earned their keep the hard way. The remainder spreads across any line with capacity, not just the last line, because the last line can be a six-cent item with no room to absorb three leftover cents. And each share is capped at its own line subtotal, because a discount larger than the line implies a negative unit price, and negative unit prices are how a ledger starts lying in the other direction.

Clamp at every edge
- Discounts clamp to the range from zero to the subtotal. A hundred and ten percent off is a typo, not a refund.
- Parse user-entered amounts defensively: empty and non-numeric mean zero, never NaN in the middle of arithmetic.
- Quantities clamp to what exists. Money code inherits every inventory bug upstream of it.
- Test the totals that do not divide evenly. Three items at ten dollars with a dollar off is a better test than any round number.
None of this is glamorous, which is exactly why it slips through review. The arithmetic looks correct because it is correct, right up until integer division quietly throws away a remainder someone was owed. Write the invariant down, sum of parts equals the whole, and make a test hold the door.
Integers keep money honest. Reconciliation keeps integers honest.