Never use floats for money, and how to enforce it
BCMath, minor units, and an architecture test that fails the build when a finance service does its arithmetic without the money helper.

Published 2026-10-15 by Arif Hossen
Tags: Finance
Source: https://arifhossen.dev/blog/never-use-floats-for-money-and-how-to-enforce-it
Where it started
I am one of the partners behind a member-owned savings-and-loan co-operative, and I built its books. A fund like that does arithmetic on every balance every day: contributions, fixed deposits, loan instalments, and a profit share that accrues to every member once a year. Ordinary floating-point arithmetic cannot represent most decimal amounts exactly. The error is a fraction of a paisa per operation, and in this system it lands on someone's savings.
So the goal was not to be careful with money. It was to make the careless version fail the build.
Floats cannot hold a decimal amount
The bug
0.1 + 0.2 === 0.3; // false
0.1 + 0.2; // 0.30000000000000004
bcadd('0.10', '0.20', 2) === '0.30'; // true
Binary floating point against BCMath at a scale of 2
Binary floating point stores 0.1 as the nearest fraction it can, which is not 0.1. One addition is invisible. A year of daily accruals over every member balance is not. And the error is not random noise. It drifts the same way for the same operations, so it grows instead of cancelling out.
Pick the representation that fits the ledger
Two answers
I have shipped two representations, and they are not interchangeable.
|
Co-operative ledger |
Household budgeting app |
| Currencies |
One |
Several, held side by side |
| Stored as |
DECIMAL(14,2), decimal strings in code |
Whole minor units, integers |
| Arithmetic |
BCMath at a fixed scale of 2 |
Integer arithmetic |
| Rates |
Separate columns, 4 decimal places |
The rate actually used, 8 decimal places, on each transaction |
| History |
Every figure recomputes from the entries |
Each row keeps its base-currency amount from the day it happened |
The co-operative works in one currency, and its partners read decimals, so fixed-scale decimal strings match how they think. The household app holds taka and dollars. There the hard part is not precision but history: converting last March's dollar payment at today's rate rewrites the past. Storing the rate used and the base amount on each row means a March report still says what it said in March, with no rates feed.
A rule nobody remembers is not a rule
Enforcement
Every money operation in the co-operative goes through one small helper: add, subtract, multiply, divide, compare and format, all at scale 2, normalising any input type first. That alone is a convention, and conventions decay the week a new calculator gets written in a hurry.
So the rule is a test. Pest architecture tests assert three things, and they run in CI.
arch('finance services do money math through the helper')
->expect('App\Services\Finance')
->toUse('App\Support\Money');
arch('models never call services')
->expect('App\Models')
->not->toUse('App\Services');
arch('controllers never query the database directly')
->expect('App\Http\Controllers')
->not->toUse('Illuminate\Support\Facades\DB');
The three architecture rules, run in CI
Breaking one is a red build, not a review comment.
Be precise about what this proves. The test fails when a finance service skips the helper. It cannot see a float multiplied inside a service that also uses the helper somewhere else. It narrows the bug class to one small, reviewed file. It does not prove the bug class is gone.
A split that always sums to the profit
The last taka
Once a year the fund's profit divides between three partners. Multiply net profit by each percentage, round each share, and the shares can miss the total by a paisa.
The split absorbs the remainder. The first partners take their percentage, rounded, and the last partner takes whatever is left, so the shares sum to net profit exactly, every time. Percentages that do not total 100 are refused outside a one-paisa tolerance.
The whole close runs in one transaction behind a preview the partners read first, and a unique constraint in the schema makes a second run impossible, not just unlikely.
The test that matched the old workbook
Proof
Before the books moved off the spreadsheet, I wrote one acceptance test. It loads opening balances from the real workbook, replays a month of activity through the real services (contributions, deposits, withdrawals, a loan and its repayments, an external borrowing), then asserts cash on hand, the balance sheet, the loan summary and the month's collections against exact decimal strings.
A one-paisa regression fails it. It runs on every commit.
The helper is the cheap part
What it cost
Writing a money helper takes an afternoon. What costs something is refusing every shortcut that goes around it, including the ones that are obviously fine today. The architecture test is how I stopped relying on myself for that.
What I have not proven: whether the system has caught an error in live data since the switch. Code cannot tell me that. Only the partners can.