Invoice totals are off by a cent
“A cart shows 0.30000000000000004, or the invoice total is one cent different from the sum of its lines, and the payment provider disagrees with our database.”
THE PRINCIPLE
Money is an integer count of minor units plus a currency. Fractions appear only in rates, and every conversion back to money is an explicit, named rounding step.
FIRST MOVES
- Find where amounts become floats: JSON parsing of 12.34, price * qty, tax percentages, and toFixed() used as rounding.
- Store and transmit integer minor units (cents, or the currency's own digits: JPY has 0, KWD has 3) with the currency code; use NUMERIC/DECIMAL or integer columns in the database.
- Pick one rounding rule (half-up, banker's) and one place to apply it: per line or per invoice. Mismatched choices are the usual one-cent diff with providers.
- Use BigInt or a decimal library when values can exceed Number.MAX_SAFE_INTEGER minor units or you need division and rates; BigInt division truncates toward zero.
- Format with Intl.NumberFormat and style: "currency"; it accepts BigInt and exact decimal strings in current engines.
TOOLS: THEN → NOW
Floats rounded with toFixed() before displayInteger minor units (number or BigInt) end to end, decimal columns in the database
Hand-built currency stringsIntl.NumberFormat with style: "currency" and an explicit roundingMode
Ad hoc percentage mathA decimal library (for example decimal.js or big.js) where rates and division are unavoidable
PATTERN SNAPSHOT
type Money = { minor: bigint; currency: string }; // 1999n USD = $19.99
const lineTotal = (unitMinor: bigint, qty: bigint) => unitMinor * qty;
// Tax in basis points, rounded half-up once (non-negative amounts).
const tax = (minor: bigint, bps: bigint) => (minor * bps + 5_000n) / 10_000n;
function display({ minor, currency }: Money, locale = "en-US") {
const fmt = new Intl.NumberFormat(locale, { style: "currency", currency });
const digits = fmt.resolvedOptions().maximumFractionDigits ?? 2; // 2 USD, 0 JPY
return fmt.format(`${minor}E-${digits}` as Intl.StringNumericLiteral); // exact decimal, no float
}CLOSE THE AI. EXPLAIN THIS.
Why does summing rounded line totals differ from rounding the summed total, and where in your system should that rounding decision live?RELATED SYMPTOMS
SOURCES
Guide reviewed