Backend
Money without floating point: the rules behind Expensio's ledger
A personal finance app is only trustworthy if every total adds up to the last unit. These are the decisions we made for Expensio's money core, including the bug where a tiny loan rounded to zero and turned into a 500 error.

Expensio is a personal finance platform: accounts, transactions, budgets, recurring payments, loans, and the people you lend money to or borrow from. Every one of those features eventually becomes arithmetic on amounts of money. If that arithmetic is off by even one paisa, users notice, because a balance that does not match the bank statement is the fastest way to lose trust in a finance app.
Before writing a single transaction endpoint, we wrote down how money works in this codebase. This post walks through those rules, why each one exists, and a real bug that slipped past them anyway.
Rule 1: double is banned for money
This is the oldest rule in financial software, and it still gets broken constantly. Binary floating point cannot represent most decimal fractions exactly:
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(1.10 - 1.00); // 0.10000000000000009
On one value the error is invisible. Across thousands of transactions, summed into balances, split into budget categories and compared against limits, it surfaces as totals that are off by a unit and checks like balance == 0 that are never true.
Expensio uses java.math.BigDecimal for every amount, everywhere, and the rule is written as an architecture decision record so nobody has to rediscover it in code review:
Money is always
java.math.BigDecimal.float/doubleare banned for money.
Rule 2: one Money type, one rounding mode
BigDecimal alone is not enough. Two BigDecimal values with different scales (10.5 and 10.50) are not equals, and nothing stops someone from adding rupees to dollars. So all money goes through a small immutable value type:
public final class Money implements Comparable<Money> {
public static final RoundingMode ROUNDING = RoundingMode.HALF_EVEN;
private static final int FALLBACK_SCALE = 2;
private final BigDecimal amount;
private final Currency currency;
public static Money of(BigDecimal amount, String currencyCode) {
Currency currency = currencyOf(currencyCode);
return new Money(round(Objects.requireNonNull(amount, "amount"), currency), currency);
}
public static int scaleOf(Currency currency) {
int digits = currency.getDefaultFractionDigits();
return digits < 0 ? FALLBACK_SCALE : digits;
}
public static BigDecimal round(BigDecimal amount, Currency currency) {
return amount.setScale(scaleOf(currency), ROUNDING);
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
// minus, negate, abs, isZero, isPositive ...
}
A few details here are deliberate:
- The scale comes from the currency. PKR and USD have two minor digits, the Japanese yen has none, the Kuwaiti dinar has three.
Currency.getDefaultFractionDigits()already knows this, so the code never hard-codes2. - Rounding is banker's rounding (
HALF_EVEN). Rounding half up always pushes.5the same way, which biases sums upward over many operations.HALF_EVENrounds.5to the nearest even digit, so the errors cancel out on average. - Mixing currencies is a programming error.
pluson two different currencies throws. Converting between currencies is a business decision with a rate and a date, not something that should happen by accident. - Equality ignores scale.
equalsusescompareTo, so10.5 PKRand10.50 PKRare the same money.
The class has no Spring imports. Every financial rule can be tested with plain JUnit in milliseconds.
Rule 3: store wider than you display
The database columns are DECIMAL(19,4), even though the app currently rounds everything to two digits:
amount DECIMAL(19,4) NOT NULL,
CONSTRAINT ck_transactions_amount CHECK (amount > 0),
Four digits is wider than the minor unit of any ISO 4217 currency. If Expensio ever supports a three-digit currency, it needs a lookup, not a schema migration on a table full of financial history.
Rule 4: amounts are positive, direction is explicit
A transaction amount is always strictly positive. Whether money goes in or out is expressed by the transaction type and by which account is the source and which is the destination, never by a minus sign.
Signed amounts look convenient until you have transfers, refunds, loan repayments and corrections in the same table. Then every report has to remember which sign means what for which type, and every mistake doubles the error instead of cancelling it. Positive amounts plus an explicit direction make each row readable on its own, and the database enforces it with a CHECK constraint.
Rule 5: controllers never compute money
All arithmetic lives in the framework-free money package: the Money type and a BalanceCalculator. Controllers validate input and call services. DTOs carry values. Nobody adds two amounts in a REST handler. When a total is wrong, there is exactly one place to look.
The bug: a loan that rounded to zero
Rules like these make most bugs impossible. They do not make all of them impossible, and the one that got through is a good illustration of where the edges are.
Expensio lets you record money you lent to someone. Creating a "lent" loan does two things in one database transaction: it opens a receivable for that person, and it posts a transaction moving the principal out of the account you paid from.
A user (in this case, a test) submitted a principal of 0.001 PKR. Here is what happened:
- The request passed validation, because
0.001is greater than zero. - The service built a
Moneyfrom it. PKR has two minor digits, so0.001rounded to0.00. - The service tried to save a loan with principal
0.00and a transaction with amount0.00. - The database rejected both with its
CHECK (principal > 0)andCHECK (amount > 0)constraints. - The API returned a 500 Internal Server Error.
Nothing was corrupted. The constraints did their job as the last line of defence. But a 500 is the wrong answer: the user made an input mistake, and the server reported it as a server crash.
The root cause is subtle. Validation ran on the raw input, and the business logic ran on the rounded value. Those are two different numbers, and only one of them was checked.
The fix validates the value the system will actually store:
Money principal = Money.of(request.principal(), currency);
if (principal.isZero()) {
throw new ValidationException(
"principal " + request.principal() + " rounds to zero in " + currency);
}
And a test pins the behaviour, including the part that matters most: nothing was opened or posted.
@Test
void lentPrincipalThatRoundsToZeroIsAValidationErrorAndOpensNothing() {
assertThatThrownBy(() -> service.create(lent("0.001", CASH)))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("rounds to zero");
verify(accountRepository, never()).save(any(Account.class));
assertThat(posted).isEmpty();
verify(loanRepository, never()).save(any(Loan.class));
}
Now the client gets a 400 with a message it can show to the user.
The general lesson
Any system that rounds has two versions of every number: what the user typed and what the system keeps. Validate the second one. Check every place where input is converted, rounded, truncated or normalised, and ask whether the checks before that point still hold after it.
The database constraint was also worth having. Without CHECK (amount > 0), this bug would not have been a loud 500. It would have been a silent zero-value loan sitting in someone's ledger, and we would have found it much later, if at all.
What we would recommend
- Ban
floatanddoublefor money in writing, not just in reviews. - Wrap amounts in a value type that knows its currency, its scale and its rounding mode.
- Store at a wider scale than you display.
- Keep amounts positive and make direction explicit.
- Put every money rule in one framework-free package that is cheap to test.
- Validate after rounding, and keep database constraints as a backstop you never rely on.
- Java
- Spring Boot
- BigDecimal
- Fintech
- Money
- Rounding
- MySQL