Never Invent a Penny: Handling Money in Code
Split a ten dollar bill three ways.
Three thirty-three each. Add them back up and you have nine dollars and ninety-nine cents. One cent has vanished, and if your software is keeping books, that cent is now a discrepancy that someone will eventually have to explain.
This sounds like a triviality. It is not. In BudgetFox, proportional splitting runs whenever an amount is allocated across categories, so the error compounds every time. Financial software has a specific failure mode: it does not crash, it just tells you something slightly untrue, indefinitely.
Two techniques solve it permanently. Neither is difficult, and both need to be decided before you write your first amount field.
One: money is never a decimal number
Start with the classic:
0.1 + 0.2
// 0.30000000000000004
This is not a JavaScript quirk. It’s binary floating point, and it applies in almost every language. A value like 0.1 has no exact binary representation, the same way one third has no exact decimal one. Every arithmetic operation on a currency amount stored as a float introduces a tiny error, and those errors accumulate in exactly the places you least want them.
The fix is to stop storing money as a fractional quantity at all. Store integer cents. $12.34 is the integer 1234. Addition and subtraction become exact, and there is no rounding to accumulate because there are no fractions to round.
The place this gets interesting is the boundary, where a human types something. People enter 12.34, $1,234.56, 19.999, a blank field, or just a stray minus sign while they’re mid-thought. All of that has to become a clean integer:
export function parseCents(raw: string | number): number {
const cleaned = String(raw).replace(/[^0-9.-]/g, '');
try {
return Number(new Big(cleaned).times(100).round(0, Big.roundHalfUp));
} catch {
return 0;
}
}
Three decisions are packed in there. Strip everything that isn’t a digit, a dot, or a sign, so currency symbols and thousands separators are handled rather than rejected. Multiply using an arbitrary-precision library rather than native arithmetic, because 19.999 * 100 in floating point is not reliably 1999.9. And resolve anything unparseable to zero instead of letting NaN through, because a NaN in an accounting system propagates silently into every total that touches it, while a zero is visible and wrong in a way a user will immediately report.
Note also that the rounding mode is stated explicitly. 19.999 becomes 2000, not 1999. Whether you round half up or half even matters less than the fact that it is written down in one place instead of being whatever your language happens to default to.
Two: distribute the remainder, don’t drop it
Integer cents fix arithmetic, but they don’t fix division. Ten dollars is 1000 cents, and 1000 / 3 is still not a whole number.
The technique here is called largest remainder. Give every bucket its floored share, count the cents left over, then hand them out one at a time to whichever buckets were cheated by the most:
export function splitProportional(totalCents: number, weights: number[]): number[] {
if (weights.length === 0) return [];
const weightSum = weights.reduce((a, b) => a + b, 0);
if (weightSum <= 0) return weights.map(() => 0);
const total = new Big(Math.trunc(totalCents));
const ideals = weights.map((w) => total.times(w).div(weightSum));
const out = ideals.map((x) => Number(x.round(0, Big.roundDown)));
let remainder = Math.trunc(totalCents) - out.reduce((a, b) => a + b, 0);
const byFraction = ideals
.map((x, i) => ({ i, frac: Number(x.minus(x.round(0, Big.roundDown))) }))
.sort((a, b) => b.frac - a.frac);
for (let k = 0; k < byFraction.length && remainder > 0; k++) {
out[byFraction[k].i] += 1;
remainder -= 1;
}
return out;
}
Walk it through with the ten dollar split. The ideal shares are 333.33 cents each. Floored, that’s 333 each, totalling 999, leaving a remainder of one cent. All three fractional parts are equal, so the first bucket takes it:
total=1000 weights=[1,1,1] -> [334, 333, 333] sum=1000
total=999 weights=[1,2,3] -> [167, 333, 499] sum=999
total=8333 weights=[1,1,1,1,1,1] -> [1389,1389,1389,1389,1389,1388] sum=8333
total=10000 weights=[50,30,20] -> [5000, 3000, 2000] sum=10000
The last case divides cleanly and gets no adjustment at all. The others don’t, and the sum still comes out exact every time. That is the guarantee worth having: the parts always add up to the whole, for any total and any weights. No penny is lost, and just as importantly, none is invented. A rounding scheme that rounds every bucket up is just as broken, it simply fails in the direction that’s harder to notice.
One deliberate asymmetry: somebody has to receive the extra cent, and it will be the same bucket every time for equal weights. That is a real bias. It’s acceptable here because the alternative, an unbalanced total, is a far worse problem, and because the amounts are one cent. If you were splitting a bill between two people every week forever, you would want to alternate. Knowing which unfairness you have chosen is the job; pretending there isn’t one is how you end up with a discrepancy.
The two rules nobody writes down
Round once, at the edge. Amounts stay as integer cents through every calculation and only become strings at the moment of display. BudgetFox has two formatters, one to the cent and one to the dollar, and both take cents and return text. Neither returns a number, so a rounded display value can never find its way back into a calculation. If a formatter can feed a computation, you will eventually round twice, and twice-rounded money is wrong money.
Test the property, not the examples. Individual cases like “1000 split three ways is 334/333/333” are worth having, but the assertion that actually protects you is structural: for any total and any set of weights, the returned parts sum to the total. That single property catches every future refactor of the algorithm. The domain logic in BudgetFox sits behind a 100% coverage gate on statements, branches, functions and lines, and the money module is the reason that gate exists rather than being an aspiration.
Why bother
A budgeting app’s only real product is trust. Not features, not charts. It is the user’s belief that the number on the screen is the truth about their money.
That trust does not break in one visible failure. It erodes one cent at a time, in a total that is off by a rounding error nobody can trace, until the user quietly goes back to a spreadsheet. Getting this right is perhaps eighty lines of code, and it is eighty lines you cannot retrofit once real data has been accumulating in the wrong shape for a year.
Decide it on day one. It is the cheapest correctness you will ever buy.
How much of your week is manual work software should be doing?
I build custom software, integrations and data migrations for teams stuck between spreadsheets and systems that don't talk to each other. Different domains, same approach: understand the work properly, then make the software carry it so nobody has to remember the rules.