We've all seen the infamous 0.1 + 0.2 !== 0.3 quirk in JavaScript. It's a parlor trick until you're building a payment system and discover you've lost money—or created it—in rounding errors across millions of transactions. This article explores money and floating point: a hands-on guide for engineers, drawn from real integration work with payment gateways where precision isn't optional.
The problem — floating point isn't designed for money
JavaScript numbers use IEEE 754 double-precision floating point, which represents values in binary. This works brilliantly for most calculations, but money demands exactness. Let's see what happens:
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log(0.01 + 0.02 + 0.03); // 0.06000000000000001
That extra noise—0.00000000000000004—might seem trivial. But in a ride-hailing platform processing 100,000 trips daily at $0.01 per minute, those errors compound. After a week, you're off by dollars. After a month, you're reconciling thousands. We've seen systems where rounding errors created phantom revenue that didn't match bank deposits, triggering audit investigations.
The root cause: decimal numbers like 0.1 can't be represented exactly in binary. Instead, the system stores the closest approximation, and every operation introduces tiny discrepancies. Add enough operations—currency conversions, tax calculations, discounts, fees—and these errors bubble up into real money.
Store minor units — integers are your foundation
The industry standard is simple: never store currency as a floating point number. Instead, store the smallest currency unit as an integer. For USD, that's cents. For EUR, also cents. For Bitcoin, satoshis. For yen, which has no fractional unit, it's whole yen.
This approach eliminates floating point entirely from your business logic:
// ❌ Bad: floating point currency
const amount = 19.99;
const tax = amount * 0.1; // 1.9990000000000002
const total = amount + tax; // 21.98900000000000002
// ✅ Good: integers (cents)
const amountCents = 1999;
const taxRate = 10; // 10%
const taxCents = Math.floor((amountCents * taxRate) / 100); // 199
const totalCents = amountCents + taxCents; // 2198 (exactly $21.98)
When you work with integers, arithmetic is exact. No approximations. No accumulated drift. A payment processor we worked with reduced reconciliation errors by 99% simply by switching their database schema from DECIMAL(10,2) columns to BIGINT columns storing cents.
The pattern is straightforward:
- Store in your database as integer minor units.
- Display to users by dividing by 100 (USD) or appropriate divisor.
- Accept user input as strings, parse to integers, validate.
- Never multiply or divide without explicit integer rounding rules.
interface Money {
amountCents: number; // Always an integer
currencyCode: string;
}
function displayMoney(money: Money): string {
const dollars = Math.floor(money.amountCents / 100);
const cents = money.amountCents % 100;
return `$${dollars}.${cents.toString().padStart(2, '0')}`;
}
function parseMoney(input: string, currency: string): Money {
const trimmed = input.trim();
const match = trimmed.match(/^(\d+)\.?(\d{0,2})$/);
if (!match) throw new Error('Invalid amount');
const dollars = parseInt(match[1], 10);
const cents = parseInt(match[2] || '0', 10).toString().padEnd(2, '0');
return {
amountCents: dollars * 100 + parseInt(cents, 10),
currencyCode: currency
};
}
// Usage
const price = parseMoney('19.99', 'USD'); // { amountCents: 1999, currencyCode: 'USD' }
console.log(displayMoney(price)); // "$19.99"
Decimal libraries — when integers aren't enough
Integer storage handles most payment scenarios. But some calculations demand decimals: currency conversions, percentage-based fees across multiple steps, or complex pricing rules. That's where libraries like decimal.js or big.js enter the picture.
These libraries use string-based arithmetic, sidestepping floating point entirely:
import Decimal from 'decimal.js';
// EUR to USD conversion with percentage fee
const eurAmount = new Decimal('100.00');
const exchangeRate = new Decimal('1.0872'); // EUR/USD
const platformFeePercent = new Decimal('2.5');
const usdAmount = eurAmount.times(exchangeRate); // 108.72 exactly
const platformFee = usdAmount.times(platformFeePercent).dividedBy(100); // 2.718
const totalUsd = usdAmount.plus(platformFee); // 111.438
console.log(totalUsd.toString()); // "111.438"
console.log(totalUsd.toFixed(2)); // "111.44"
In a CRM system handling multi-currency invoicing, we used Decimal for intermediate calculations, then converted the final result to integer cents for storage. This hybrid approach gave us precision without compromising database design:
import Decimal from 'decimal.js';
interface InvoiceLineItem {
description: string;
quantityCents: number; // stored as integer
unitPriceCents: number; // stored as integer
taxRate: string; // stored as string for precision
}
function calculateLineTotal(item: InvoiceLineItem): number {
const quantity = new Decimal(item.quantityCents).dividedBy(100);
const unitPrice = new Decimal(item.unitPriceCents).dividedBy(100);
const taxRate = new Decimal(item.taxRate);
const subtotal = quantity.times(unitPrice);
const tax = subtotal.times(taxRate).dividedBy(100);
const total = subtotal.plus(tax);
// Convert back to cents (round half-up)
return Math.round(total.times(100).toNumber());
}
// Example
const line: InvoiceLineItem = {
description: 'Widget',
quantityCents: 300, // 3 units
unitPriceCents: 2550, // $25.50 each
taxRate: '8.5' // 8.5% tax
};
const totalCents = calculateLineTotal(line);
console.log(displayMoney({ amountCents: totalCents, currencyCode: 'USD' }));
// Output: $82.81
A few guidelines:
- Use Decimal for multi-step calculations involving percentages, exchange rates, or tax.
- Minimize the scope: fetch integer values from the database, do Decimal math, store the result as an integer.
- Always convert final results back to integer minor units before persisting.
- Document which columns use Decimal calculations so future maintainers know why.
Round once — control the rounding moment
Rounding is unavoidable in money. The key is doing it exactly once, at the right moment, with explicit rules. Every extra round introduces error.
Consider a distributor system where we split an $100 invoice across three warehouses:
// ❌ Bad: rounding at each step
const total = 10000; // cents
const share1 = Math.round(total * (1/3)); // 3334
const share2 = Math.round(total * (1/3)); // 3334
const share3 = Math.round(total * (1/3)); // 3334
const sum = share1 + share2 + share3; // 10002 — $0.02 discrepancy!
// ✅ Good: round at the end, handle remainder
const totalToSplit = 10000;
const parts = 3;
const baseCents = Math.floor(totalToSplit / parts); // 3333
const remainder = totalToSplit % parts; // 1
const shares = [
baseCents + (0 < remainder ? 1 : 0), // 3334
baseCents + (1 < remainder ? 1 : 0), // 3333
baseCents + (2 < remainder ? 1 : 0), // 3333
];
console.log(shares.reduce((a, b) => a + b, 0)); // 10000 — exact!
Establish a rounding policy and document it. Common strategies:
- Round half-up: 2.5 → 3, 2.4 → 2. Standard for money in most jurisdictions.
- Round half-to-even (banker's rounding): Reduces bias over many operations. Used by some accounting systems.
- Round down (floor): Conservative; used for fees charged to customers.
- Round up (ceiling): Conservative; used for fees owed by the platform.
function roundCents(
decimal: Decimal,
direction: 'up' | 'down' | 'half-up' = 'half-up'
): number {
const shifted = decimal.times(100);
switch (direction) {
case 'up':
return Math.ceil(shifted.toNumber());
case 'down':
return Math.floor(shifted.toNumber());
case 'half-up':
default:
// Decimal.js supports ROUND_HALF_UP
return shifted.toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toNumber();
}
}
// Tax calculation: round up (to our benefit)
const subtotal = new Decimal('19.99');
const taxRate = new Decimal('8.875'); // Texas tax
const tax = subtotal.times(taxRate).dividedBy(100);
const taxCents = roundCents(tax, 'up');
console.log(taxCents); // 178 cents, not 177.575 cents rounded
Common mistakes
- Mixing floating point and integers: If you store cents but accept user input as floats, you've lost precision before it reaches your system. Always parse strings or use Decimal first.
- Rounding in intermediate steps: Each round multiplies error. Calculate everything, then round once at the end.
- Forgetting the currency code: A number alone is meaningless. Always pair amount with currency to avoid silent USD/EUR confusion.
- Not testing edge cases: Test amounts like $0.01, $999,999.99, and non-round percentages. Floating point errors hide until production.
- Using Decimal everywhere: Overkill for simple addition. Use integers by default, Decimal only when you need it. Performance matters at scale.
Key takeaways
- Floating point arithmetic is approximate; money requires exactness. Never store currency as a float.
- Store amounts as integers in the smallest unit (cents for USD). Arithmetic on integers is exact.
- Use Decimal libraries only for complex calculations (percentages, conversions, tax). Convert results back to integers before storage.
- Round exactly once, at a defined point, with explicit rounding rules. Document your policy.
- Test thoroughly with edge cases. Rounding errors compound and surface in reconciliation, not immediately.






Comments
Loading comments…