Money is an integer
Every money bug I have fixed in ten years came back to one of three mistakes. All three go away when you store sen, not ringgit.
Every money bug I have fixed in ten years of payments work came back to one of three mistakes. Someone stored an amount as a floating point number. Someone rounded in the wrong place. Or someone changed a balance without writing down why. This post is about the first two, because they are the cheapest to prevent and the most expensive to clean up.
The rule I give every team is short: money is an integer. You store the smallest unit the currency uses, you do arithmetic on whole numbers, and you only turn it into a decimal string at the very last moment, on the screen or in a file for a bank.
Why floats lie
A floating point number is a binary fraction. Most decimal amounts cannot be written exactly in binary, the same way one third cannot be written exactly in decimal. RM 0.10 is stored as something very close to 0.1, but not quite. Add it up a thousand times and you get a number that is very close to 100, but not quite.
> 0.1 + 0.20.30000000000000004> [...Array(1000)].reduce((s) => s + 0.1, 0)99.9999999999986
On its own, a missing fraction of a sen does no harm. The harm comes later, when that number is compared to another one. A balance check says 99.9999999999986 is less than 100, so a valid payment is declined. A reconciliation job says two totals differ, so an analyst spends an afternoon looking for money that was never missing. I once traced a week of failed refunds at a wallet company to a single line that parsed an amount with parseFloat before comparing it.
Decimal types in your database are better, and I have no quarrel with a NUMERIC column. The trouble is that the amount leaves the database. It goes into JSON, into a JavaScript client, into a spreadsheet export, into a message on a queue. Each of those hops is a chance for someone to turn it back into a float without noticing. An integer survives every hop unchanged.
Pick the unit per currency
For ringgit the minor unit is the sen, so RM 12.50 is stored as 1250. Most currencies in the region work the same way, with two decimal places. Some do not. The Indonesian rupiah and the Vietnamese dong officially have no minor unit in daily use, so an amount of 15,000 rupiah is stored as 15000. A few currencies elsewhere use three decimal places.
The mistake is to hard-code a factor of 100. Keep a small table of currencies and their exponent, and always carry the currency next to the amount. An amount without a currency is not money, it is a number waiting to be misread.
type Money = { amount: bigint; currency: "MYR" | "IDR" | "SGD" | "PHP" };const EXPONENT = { MYR: 2, IDR: 0, SGD: 2, PHP: 2 } as const;export function format(m: Money, locale = "en-MY") { const e = EXPONENT[m.currency]; const value = Number(m.amount) / 10 ** e; // display only, never stored return new Intl.NumberFormat(locale, { style: "currency", currency: m.currency, minimumFractionDigits: e, }).format(value);}
Note the bigint. A 64-bit integer of sen holds about 92 quadrillion ringgit, which is enough for any wallet I have worked on. JavaScript's normal number type is only safe up to about 90 trillion sen, which sounds like a lot until someone sums every transaction for a year in one query. Use the big type at the edges where it matters, and be explicit when you convert.
Round once, on purpose
Integers do not remove rounding. They make it visible, which is the point. Fees, tax and currency conversion all produce fractions of a sen, and you have to decide what happens to that fraction.
Three rules have served me well. First, round at one place in the code, in a function with a name, not in whatever line happens to do a division. Second, write down which rounding mode you use and why. Banker's rounding, half to even, avoids a slow upward drift over millions of rows, but a regulator or a contract may require half up. Third, when you split an amount, make sure the parts add back to the whole.
That last one catches people. Split RM 10.00 three ways and each share is 333.33 sen. Round each and you have 999 sen. One sen has vanished. Over a month of group bills or instalment plans that sen becomes real money that does not reconcile.
// allocate splits total into n parts that always sum back to total.func allocate(total int64, n int) []int64 { out := make([]int64, n) base, rem := total/int64(n), total%int64(n) for i := range out { out[i] = base if int64(i) < rem { out[i]++ // hand out the leftover sen one at a time } } return out}
The leftover sen go to the first parts. Some teams give them to the last part, or to the largest. It does not matter much which you pick, as long as you pick one and the parts always add up.
Conversion is a transaction, not a format
Currency conversion deserves its own warning. When a customer sends RM 500 to the Philippines, the conversion is not a display step. It is a trade at a specific rate, at a specific time, and the result in pesos is a new amount that must be stored as its own integer, with the rate that produced it next to it.
If you store only the ringgit and recompute the pesos later, you will one day recompute with a different rate, or a different rounding, and your records will disagree with what the customer was shown. On the Lintasan quote engine, every shown rate is logged with the inputs that produced it, so any number on any screen can be rebuilt exactly, years later.
What this buys you
None of this is clever. That is why it works. Once amounts are integers with a currency attached, a lot of other good things become easy. A ledger can check that every journal sums to zero, and that check is an exact comparison, not a tolerance. Tests can assert exact values. Exports to banks and accounting software stop producing the odd one-sen difference that someone has to explain to an auditor.
An amount without a currency is not money. It is a number waiting to be misread.
If you are starting a new product that will touch money, put this in place on day one. It costs an afternoon. If you are working on an old one, start at the edges: the API, the queue messages, the exports. Make the integer the contract between systems, and the floats will slowly lose their places to hide.
Next time I will write about the third mistake, changing balances without writing down why, and how an append-only ledger turns a month-end close from a week into an afternoon.