Have you ever seen a balance off by one cent? In finance, even the smallest error is a red flag. A rounding mistake that looks harmless in code can accumulate into losses, reconciliation headaches, or opportunities for abuse. The first principle of reliable money systems is treating money as data with exact rules.
What does it mean to treat money as data?
Money is not a generic number. It has precision rules set by the currency:
- Each currency has a major unit (USD → dollars, NGN → naira, JPY → yen).
- Each currency also defines a minor unit (USD → cents, NGN → kobo, JPY → none).
- Amounts must be exact, not approximate.
- Totals must remain consistent across systems, reports, and statements.
Ignore these, and you get drifting balances, failed reconciliation, and loss of trust.
Why floats fail
A float (floating-point number) is an approximate representation of real numbers in binary. Many decimal fractions, like 0.1, cannot be represented exactly, so arithmetic introduces tiny errors.
0.1 + 0.2
// 0.30000000000000004
That might be acceptable in physics. In finance, that “extra fraction of a cent” becomes a liability.
Instead:
- Use integers for amounts stored in minor units.
- Use fixed-point decimals (for example,
DECIMAL(p,s)
in SQL) only when more fractional precision is required, such as FX rates or interest calculations.
Major vs Minor Units
Minor unit (recommended for storage and computation)
- Store the smallest indivisible unit as an integer.
Example:
100.00 USD
→10000
cents. - Benefits: exact arithmetic, simple aggregation, consistent across services.
Major unit (allowed with care)
- Store dollars or naira as
100.00
. Use fixed-point decimals and enforce scale. - Risks: silent rounding in some languages and ORMs, accidental float usage, and harder migrations when adding currencies with different minor units.
💡 Best practice: store and compute in minor units (integers), and use major units for input and display at the edges (APIs, UI, reports).
Applying this in your system
When handling money in software, consistency matters more than anything else. Here’s a practical flow you can adopt:
- Accept amounts in major units and convert to minor units internally, or accept minor units directly.
-
Validate the submitted amount against the currency’s allowed scale (minor unit).
- JPY minor unit is 0 → reject
100.15 JPY
. - USD minor unit is 2 → reject
100.123 USD
. - KWD minor unit is 3 → accept
10.125 KWD
. > ❌ Never silently round. Always reject invalid input with a clear error.
- JPY minor unit is 0 → reject
Example error response:
{
"error": "INVALID_AMOUNT_SCALE",
"message": "JPY does not allow fractional amounts. Submit an integer value."
}
- Convert to minor units on the server and include the currency code.
- Use minor units for all calculations in your code.
- Store minor units in the database.
- Render back to major units in APIs, UI, and reports for readability.
TypeScript example (server-side):
type CurrencyMeta = { code: string; minorUnit: number };
const CURRENCIES: Record<string, CurrencyMeta> = {
JPY: { code: "JPY", minorUnit: 0 },
USD: { code: "USD", minorUnit: 2 },
KWD: { code: "KWD", minorUnit: 3 },
};
function validateScale(amountMajor: string, currency: string) {
const meta = CURRENCIES[currency];
if (!meta) throw new Error("UNSUPPORTED_CURRENCY");
if (!/^-?\d+(\.\d+)?$/.test(amountMajor)) throw new Error("INVALID_AMOUNT_FORMAT");
const [, , frac = ""] = amountMajor.match(/^(-?\d+)(?:\.(\d+))?$/) || [];
if (frac.length > meta.minorUnit) throw new Error("INVALID_AMOUNT_SCALE");
}
function toMinorUnits(amountMajor: string, minorUnit: number): bigint {
const [intPart, fracPart = ""] = amountMajor.split(".");
const frac = (fracPart + "0".repeat(minorUnit)).slice(0, minorUnit);
return BigInt(`${intPart}${frac}`);
}
// Usage
validateScale("100.00", "USD");
const minor = toMinorUnits("100.00", 2); // 10000 cents
Migration considerations
If your system currently stores amounts in major units:
- Audit where arithmetic is performed.
- Add a new
minor_amount
column for minor units. - Backfill:
minor_amount = amount * 10^scale(currency)
. - Switch reads and writes to
minor_amount
. - Keep accepting major units at the API but convert internally.
- Run reconciliation before and after migration to confirm balances.
Takeaway
Applying this pattern ensures financial accuracy across your system:
- Accept amounts in major units and convert to minor units internally, or accept minor units directly.
- Convert to minor units internally.
- Compute and store only in minor units.
- Expose major units back at the edges.
Do this well, and you build a system that never loses a cent. This is also a solid foundation for accounts, double-entry, ledgers, and reporting.
Next in the series
- Accounts and Balances: Understanding the different types of balances, where and how to use them.
Top comments (0)