Money: integer cents, rounding, currencies
Never store money in a binary float: 0.1 + 0.2 is not 0.3. Store an integer count of the currency’s minor unit (cents for USD, yen for JPY, fils for BHD), keep the currency next to it, and do every multiplication or division in integers. Round once, at a defined point, with a named rule (half-up or half-even). When splitting, allocate the leftover units explicitly so the parts always sum to the whole.
The idea in plain words
Section titled “The idea in plain words”A float stores numbers in binary, the way 1/3 can only be written approximately in decimal (0.3333…). Most decimal fractions, like one tenth, have no exact binary form. So 0.10 is stored as a number a hair above one tenth, and every addition rounds again. The errors are tiny, but money code compares with ==, sums millions of values, and must reconcile to the cent.
The fix is old and simple: count whole minor units. $19.99 is the integer 1999. Integers add, subtract and compare exactly. The only operations that can produce fractions are multiplication by a rate (tax, fees, FX) and division (splits, averages), and those are exactly the places where you decide how to round.
Three more facts complete the picture:
- Currencies have different minor units. ISO 4217 gives each currency an exponent: USD and EUR have 2 decimals, JPY and KRW have 0, BHD, KWD and JOD have 3. “Multiply by 100” is a bug waiting for the first yen payment.
- An amount without a currency is meaningless.
1000could be $10.00 or ¥1,000. Keep(amount, currency)together and refuse to add different currencies. - Rounding needs a rule. “Round half up” and “round half to even” give different answers on exact ties, and the spec decides which one is correct.
See it
Section titled “See it”Type any two decimals to see what the computer actually stores. Then add a price many times: with 0.10 the float total is already wrong after 3 additions, while integer cents stay exact.
total == 1.00 fails, and a report rounded to cents can be off by one when the drift crosses a half. Integer cents are exact up to 253 − 1 = 9,007,199,254,740,991 in a JavaScript number, and without limit in a Python int or a JS BigInt.When to reach for it
Section titled “When to reach for it”| The problem says… | What to do |
|---|---|
| amounts “in cents” / “minor units” / “integer amounts” | Keep them as integers end to end. Don’t convert to dollars to compute. |
| a percentage, basis points (1 bp = 0.01%), a rate in parts per million | Multiply first, divide last, in integers; round once with the stated rule. |
| “rounded to the nearest cent, halves rounded up” | That’s a named mode. Implement it exactly, including negatives. |
| split a bill, pro-rate a refund, distribute fees | Allocation: floor the shares, then hand out the leftover units. |
| several currencies | A currency code on every amount, an exponent table, explicit conversion. |
| “costs are fractions of a cent; round only when reporting” | Keep a finer integer unit (or an exact rational) internally and round at the edge. |
How it works
Section titled “How it works”Rounding an integer division
Section titled “Rounding an integer division”Rates turn integers into fractions: 8.25% of 1999 cents is 1999 × 825 / 10000 = 164.9175 cents. You want a whole number, computed without ever leaving integers.
- Take the magnitude and remember the sign, so negative amounts (refunds) round symmetrically.
- Compare the remainder with half the divisor as
2 × rvsd. No floats, no0.5. - Above half: round up. Below: keep. Exactly half: the mode decides. Half-up (away from zero) rounds up; half-even rounds to the even neighbour.
from typing import Literal
Mode = Literal["half_up", "half_even", "floor"]
def div_round(n: int, d: int, mode: Mode) -> int: """n / d rounded to an integer with exact integer math. d > 0.""" if d <= 0: raise ValueError("d must be positive") if mode == "floor": return n // d # Python's // already floors (toward -infinity) q, r = divmod(abs(n), d) # 1. work on the magnitude, fix the sign at the end twice = 2 * r # 2. compare r with d/2 without leaving integers if twice > d or (twice == d and (mode == "half_up" or q % 2 == 1)): q += 1 # 3. above half: up; exactly half: depends on mode return q if n >= 0 else -q # "half_up" here means half away from zero
if __name__ == "__main__": # 2.5 3.5 -2.5 2.4 2.6 tenths = [25, 35, -25, 24, 26] assert [div_round(x, 10, "half_up") for x in tenths] == [3, 4, -3, 2, 3] assert [div_round(x, 10, "half_even") for x in tenths] == [2, 4, -2, 2, 3] assert [div_round(x, 10, "floor") for x in tenths] == [2, 3, -3, 2, 2] # 8.25% tax on $19.99, in cents: 1999 * 825 / 10000 = 164.9175 -> 165 assert div_round(1999 * 825, 10_000, "half_even") == 165 # Python's round() is half-even too, but on binary floats: 2.675 is really 2.67499999... assert round(2.5) == 2 and round(2.675, 2) == 2.67 print("ok")import assert from "node:assert/strict";
type Mode = "half_up" | "half_even" | "floor";
/** n / d rounded to an integer with exact bigint math. d > 0. */export function divRound(n: bigint, d: bigint, mode: Mode): bigint { if (d <= 0n) throw new RangeError("d must be positive"); const neg = n < 0n; const mag = neg ? -n : n; // 1. work on the magnitude, fix the sign at the end let q = mag / d; // bigint division truncates toward zero const r = mag % d; if (mode === "floor") return neg && r !== 0n ? -q - 1n : neg ? -q : q; const twice = 2n * r; // 2. compare r with d/2 without leaving integers if (twice > d || (twice === d && (mode === "half_up" || q % 2n === 1n))) q += 1n; // 3. return neg ? -q : q; // "half_up" here means half away from zero}
const tenths = [25n, 35n, -25n, 24n, 26n]; // 2.5 3.5 -2.5 2.4 2.6assert.deepEqual(tenths.map((x) => divRound(x, 10n, "half_up")), [3n, 4n, -3n, 2n, 3n]);assert.deepEqual(tenths.map((x) => divRound(x, 10n, "half_even")), [2n, 4n, -2n, 2n, 3n]);assert.deepEqual(tenths.map((x) => divRound(x, 10n, "floor")), [2n, 3n, -3n, 2n, 2n]);// 8.25% tax on $19.99, in cents: 1999 * 825 / 10000 = 164.9175 -> 165assert.equal(divRound(1999n * 825n, 10_000n, "half_even"), 165n);// The built-ins are not what you want for money:assert.equal(Math.round(-2.5), -2); // halves go toward +infinity, not away from zeroassert.equal((1.005).toFixed(2), "1.00"); // 1.005 is really 1.00499999999999989...console.log("ok");long[] tenths = [25, 35, -25, 24, 26]; // 2.5 3.5 -2.5 2.4 2.6Check(tenths.Select(x => Money.DivRound(x, 10, Mode.HalfUp)), [3, 4, -3, 2, 3]);Check(tenths.Select(x => Money.DivRound(x, 10, Mode.HalfEven)), [2, 4, -2, 2, 3]);Check(tenths.Select(x => Money.DivRound(x, 10, Mode.Floor)), [2, 3, -3, 2, 2]);// 8.25% tax on $19.99, in cents: 1999 * 825 / 10000 = 164.9175 -> 165if (Money.DivRound(1999 * 825, 10_000, Mode.HalfEven) != 165) throw new Exception("tax");// decimal is base-10, so 2.675m really is 2.675. Math.Round defaults to banker's rounding.if (Math.Round(2.675m, 2) != 2.68m || Math.Round(2.5m) != 2m) throw new Exception("ToEven default");if (Math.Round(2.5m, MidpointRounding.AwayFromZero) != 3m) throw new Exception("AwayFromZero");Console.WriteLine("ok");
static void Check(IEnumerable<long> got, long[] want){ if (!got.SequenceEqual(want)) throw new Exception($"got {string.Join(",", got)}");}
enum Mode { HalfUp, HalfEven, Floor }
static class Money{ /// n / d rounded to an integer with exact integer math. d > 0. public static long DivRound(long n, long d, Mode mode) { if (d <= 0) throw new ArgumentOutOfRangeException(nameof(d)); long q = Math.DivRem(Math.Abs(n), d, out long r); // 1. magnitude first; C# / truncates if (mode == Mode.Floor) return n >= 0 ? q : (r == 0 ? -q : -q - 1); long twice = 2 * r; // 2. compare r with d/2 in integers if (twice > d || (twice == d && (mode == Mode.HalfUp || q % 2 == 1))) q++; // 3. return n >= 0 ? q : -q; // HalfUp = half away from zero }}The built-in rounders are traps on floats. Python’s round(2.675, 2) is 2.67 because the float is really 2.67499999…. JavaScript’s Math.round(-2.5) is -2, because it rounds halves toward +∞. On C#’s base-10 decimal, Math.Round(2.675m, 2) really is a tie, and it gives 2.68 under the default MidpointRounding.ToEven.
Compare the modes
Section titled “Compare the modes”Ties are where the modes differ. Half-up is what people learn at school; half-even (banker’s rounding) sends ties to the even neighbour, so over many values it rounds up as often as down and the total doesn’t drift. Edit the values, or pick the cents preset to see float toFixed disagree with both.
| value | half away from 0 | half to even | half toward +∞ | floor | ceil | truncate | float toFixed |
|---|---|---|---|---|---|---|---|
| 0.5 | 1 | 0 | 1 | 0 | 1 | 0 | 1 |
| 1.5 | 2 | 2 | 2 | 1 | 2 | 1 | 2 |
| 2.5 | 3 | 2 | 3 | 2 | 3 | 2 | 3 |
| 3.5 | 4 | 4 | 4 | 3 | 4 | 3 | 4 |
| 4.5 | 5 | 4 | 5 | 4 | 5 | 4 | 5 |
| -0.5 | -1 | 0 | 0 | -1 | 0 | 0 | -1 |
| -1.5 | -2 | -2 | -1 | -2 | -1 | -1 | -2 |
| -2.5 | -3 | -2 | -2 | -3 | -2 | -2 | -3 |
| sum (exact 8.0) | 9 +1.0 | 8 no drift | 12 +4.0 | 4 -4.0 | 12 +4.0 | 7 -1.0 |
2.675 as a double is 2.67499999…, so the float was never a tie at all.| Mode | 2.5 | 3.5 | −2.5 | Where you meet it |
|---|---|---|---|---|
| half away from zero (“half up”) | 3 | 4 | −3 | school, most invoices, Python ROUND_HALF_UP, .NET AwayFromZero |
| half to even (banker’s) | 2 | 4 | −2 | Python round() and decimal’s default context, .NET Math.Round, IEEE 754 default |
| half toward +∞ | 3 | 4 | −2 | JavaScript Math.round |
| floor | 2 | 3 | −3 | Python // and math.floor |
| truncate | 2 | 3 | −2 | C#, Java and JS Math.trunc integer division |
Splitting without losing a cent
Section titled “Splitting without losing a cent”$100.00 split three ways is $33.333… each. Round each share and you hand out $99.99. A cent vanished, and the ledger won’t balance. The fix is the largest remainder method (also called Hamilton’s method), which Martin Fowler’s Money pattern popularized as allocate:
- Floor every share:
total × weight ÷ sum_of_weights, in integers. - The leftover is
total − sum(floors), always fewer units than there are parties. - Give the leftover out one minor unit at a time to the parties with the largest remainders. Break ties with a stated rule (here: the earliest party).
| party | weight | exact share | round each share | largest remainder |
|---|---|---|---|---|
| A | 1 | 33.333333… | 33.33 | 33.34 (+0.01) |
| B | 1 | 33.333333… | 33.33 | 33.33 |
| C | 1 | 33.333333… | 33.33 | 33.33 |
| sum | 3 | 100.00 | 99.99 | 100.00 |
def allocate(total: int, weights: list[int]) -> list[int]: """Split total minor units in proportion to weights; the parts always sum to total.""" if total < 0 or not weights or min(weights) < 0 or sum(weights) == 0: raise ValueError("need total >= 0 and non-negative weights with a positive sum") w_sum = sum(weights) shares = [total * w // w_sum for w in weights] # 1. floor every share leftover = total - sum(shares) # 2. 0 <= leftover < len(weights) # 3. hand the leftover units out, one each, to the largest remainders (ties: earliest) by_remainder = sorted(range(len(weights)), key=lambda i: (-(total * weights[i] % w_sum), i)) for i in by_remainder[:leftover]: shares[i] += 1 return shares
if __name__ == "__main__": assert allocate(100, [1, 1, 1]) == [34, 33, 33] # $1.00 three ways assert allocate(1000, [70, 20, 10]) == [700, 200, 100] # exact: no leftover assert allocate(5, [3, 7]) == [2, 3] # 1.5 and 3.5: tie goes to the first assert allocate(1001, [1, 2, 3, 4]) == [100, 200, 300, 401] naive = [round(100 * w / 3) for w in (1, 1, 1)] # per-share rounding: [33, 33, 33] assert sum(naive) == 99 and sum(allocate(100, [1, 1, 1])) == 100 # a cent vanished print("ok")import assert from "node:assert/strict";
/** Split total minor units in proportion to weights; the parts always sum to total. */export function allocate(total: bigint, weights: bigint[]): bigint[] { const wSum = weights.reduce((a, b) => a + b, 0n); if (total < 0n || weights.length === 0 || weights.some((w) => w < 0n) || wSum === 0n) { throw new RangeError("need total >= 0 and non-negative weights with a positive sum"); } const shares = weights.map((w) => (total * w) / wSum); // 1. floor every share (bigint / truncates) let leftover = total - shares.reduce((a, b) => a + b, 0n); // 2. 0 <= leftover < weights.length // 3. hand the leftover out, one unit each, to the largest remainders (ties: earliest) const rem = weights.map((w) => (total * w) % wSum); const order = weights.map((_, i) => i).sort((a, b) => (rem[b] > rem[a] ? 1 : rem[b] < rem[a] ? -1 : a - b)); for (const i of order) { if (leftover === 0n) break; shares[i] += 1n; leftover -= 1n; } return shares;}
assert.deepEqual(allocate(100n, [1n, 1n, 1n]), [34n, 33n, 33n]); // $1.00 three waysassert.deepEqual(allocate(1000n, [70n, 20n, 10n]), [700n, 200n, 100n]); // exact: no leftoverassert.deepEqual(allocate(5n, [3n, 7n]), [2n, 3n]); // 1.5 and 3.5: tie goes to the firstassert.deepEqual(allocate(1001n, [1n, 2n, 3n, 4n]), [100n, 200n, 300n, 401n]);const naive = [1, 1, 1].map((w) => Math.round((100 * w) / 3)); // per-share rounding: [33, 33, 33]assert.equal(naive.reduce((a, b) => a + b), 99); // a cent vanishedconsole.log("ok");Check(Money.Allocate(100, [1, 1, 1]), [34, 33, 33]); // $1.00 three waysCheck(Money.Allocate(1000, [70, 20, 10]), [700, 200, 100]); // exact: no leftoverCheck(Money.Allocate(5, [3, 7]), [2, 3]); // 1.5 and 3.5: tie goes to the firstCheck(Money.Allocate(1001, [1, 2, 3, 4]), [100, 200, 300, 401]);var naive = new[] { 1, 1, 1 }.Select(w => (long)Math.Round(100m * w / 3)).Sum(); // [33, 33, 33]if (naive != 99) throw new Exception("per-share rounding loses a cent");Console.WriteLine("ok");
static void Check(long[] got, long[] want){ if (!got.SequenceEqual(want)) throw new Exception($"got {string.Join(",", got)}");}
static class Money{ /// Split total minor units in proportion to weights; the parts always sum to total. public static long[] Allocate(long total, long[] weights) { long wSum = weights.Sum(); if (total < 0 || weights.Length == 0 || weights.Any(w => w < 0) || wSum == 0) throw new ArgumentException("need total >= 0 and non-negative weights with a positive sum"); long[] shares = weights.Select(w => total * w / wSum).ToArray(); // 1. floor every share long leftover = total - shares.Sum(); // 2. 0 <= leftover < n // 3. hand the leftover out, one unit each, to the largest remainders (ties: earliest) var order = Enumerable.Range(0, weights.Length) .OrderByDescending(i => total * weights[i] % wSum).ThenBy(i => i); foreach (int i in order.Take((int)leftover)) shares[i]++; return shares; }}Minor units per currency
Section titled “Minor units per currency”At the boundary, money arrives as text ("12.34") or as an already-integer amount. Parse text with a base-10 type or by hand, and reject amounts with more decimals than the currency allows instead of silently rounding them.
from decimal import Decimal, InvalidOperation
# ISO 4217 minor-unit exponents: how many digits after the decimal point.EXPONENT = {"USD": 2, "EUR": 2, "GBP": 2, "JPY": 0, "KRW": 0, "BHD": 3, "KWD": 3}
def to_minor(amount: str, currency: str) -> int: """'12.34' USD -> 1234. Parse the text as Decimal, never as float.""" exp = EXPONENT[currency] # 1. KeyError for unknown currencies try: d = Decimal(amount) # 2. base-10: "0.1" is exactly 0.1 except InvalidOperation: raise ValueError(f"not a number: {amount!r}") from None if not d.is_finite(): raise ValueError(f"not a number: {amount!r}") scaled = d.scaleb(exp) # 3. shift the point by the exponent if scaled != scaled.to_integral_value(): # 4. refuse to round silently raise ValueError(f"{amount} has more than {exp} decimals for {currency}") return int(scaled)
def format_minor(units: int, currency: str) -> str: exp = EXPONENT[currency] return f"{Decimal(units).scaleb(-exp):.{exp}f}" # 5. display only; never compute on this
if __name__ == "__main__": assert to_minor("12.34", "USD") == 1234 assert to_minor("1500", "JPY") == 1500 # yen has no minor unit assert to_minor("1.234", "BHD") == 1234 # dinar has three assert format_minor(1234, "BHD") == "1.234" and format_minor(5, "USD") == "0.05" assert format_minor(1500, "JPY") == "1500" for bad, ccy in [("12.345", "USD"), ("0.5", "JPY"), ("12,34", "USD"), ("NaN", "USD")]: try: to_minor(bad, ccy) raise AssertionError(bad) except ValueError: pass print("ok")import assert from "node:assert/strict";
// ISO 4217 minor-unit exponents: how many digits after the decimal point.const EXPONENT: Record<string, number> = { USD: 2, EUR: 2, GBP: 2, JPY: 0, KRW: 0, BHD: 3, KWD: 3 };
/** "12.34" USD -> 1234n. Works on the text; no float ever touches the amount. */export function toMinor(amount: string, currency: string): bigint { const exp = EXPONENT[currency]; // 1. unknown currency is an error, not a default of 2 if (exp === undefined) throw new RangeError(`unknown currency ${currency}`); const m = /^(-?)(\d+)(?:\.(\d+))?$/.exec(amount); // 2. strict decimal text if (!m) throw new RangeError(`not a number: '${amount}'`); const [, sign, whole, frac = ""] = m; if (frac.length > exp) throw new RangeError(`${amount} has more than ${exp} decimals for ${currency}`); // 4. const units = BigInt(whole + frac.padEnd(exp, "0")); // 3. shift the point by the exponent return sign ? -units : units;}
export function formatMinor(units: bigint, currency: string): string { const exp = EXPONENT[currency]; // 5. display only; Intl.NumberFormat would add symbols and grouping const s = (units < 0n ? -units : units).toString().padStart(exp + 1, "0"); const body = exp ? `${s.slice(0, -exp)}.${s.slice(-exp)}` : s; return units < 0n ? `-${body}` : body;}
assert.equal(toMinor("12.34", "USD"), 1234n);assert.equal(toMinor("1500", "JPY"), 1500n); // yen has no minor unitassert.equal(toMinor("1.234", "BHD"), 1234n); // dinar has threeassert.equal(toMinor("0.1", "USD") + toMinor("0.2", "USD"), 30n); // exact, unlike 0.1 + 0.2assert.equal(formatMinor(1234n, "BHD"), "1.234");assert.equal(formatMinor(5n, "USD"), "0.05");assert.equal(formatMinor(1500n, "JPY"), "1500");for (const [bad, ccy] of [["12.345", "USD"], ["0.5", "JPY"], ["12,34", "USD"], ["1e3", "USD"]]) { assert.throws(() => toMinor(bad, ccy));}console.log("ok");using System.Globalization;
if (Money.ToMinor("12.34", "USD") != 1234) throw new Exception("USD");if (Money.ToMinor("1500", "JPY") != 1500) throw new Exception("JPY has no minor unit");if (Money.ToMinor("1.234", "BHD") != 1234) throw new Exception("BHD has three");if (0.1m + 0.2m != 0.3m) throw new Exception("decimal is base-10, so this is exact");if (Money.Format(1234, "BHD") != "1.234" || Money.Format(5, "USD") != "0.05" || Money.Format(1500, "JPY") != "1500") throw new Exception("format");foreach (var (bad, ccy) in new[] { ("12.345", "USD"), ("0.5", "JPY"), ("12,34", "USD"), ("abc", "USD") }){ try { Money.ToMinor(bad, ccy); throw new Exception($"accepted {bad}"); } catch (FormatException) { }}Console.WriteLine("ok");
static class Money{ // ISO 4217 minor-unit exponents: how many digits after the decimal point. static readonly Dictionary<string, int> Exponent = new() { ["USD"] = 2, ["EUR"] = 2, ["GBP"] = 2, ["JPY"] = 0, ["KRW"] = 0, ["BHD"] = 3, ["KWD"] = 3 };
/// "12.34" USD -> 1234. decimal is base-10 with 28-29 significant digits. public static long ToMinor(string amount, string currency) { int exp = Exponent[currency]; // 1. KeyNotFoundException for unknown currencies // 2. invariant culture: in de-DE, "12,34" would parse as 12.34 if (!decimal.TryParse(amount, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out decimal d)) throw new FormatException($"not a number: '{amount}'"); decimal scaled = d * (decimal)Math.Pow(10, exp); // 3. shift the point by the exponent if (scaled != decimal.Truncate(scaled)) // 4. refuse to round silently throw new FormatException($"{amount} has more than {exp} decimals for {currency}"); return (long)scaled; }
public static string Format(long units, string currency) // 5. display only { int exp = Exponent[currency]; return (units / (decimal)Math.Pow(10, exp)).ToString($"F{exp}", CultureInfo.InvariantCulture); }}| Python | TypeScript / JavaScript | C# | |
|---|---|---|---|
| Integer amounts | int: arbitrary precision, never overflows |
number is exact only up to 2^53 − 1 (Number.MAX_SAFE_INTEGER); use bigint (123n) beyond that |
long (±9.2 × 10^18); checked { } to trap overflow |
| Base-10 type | decimal.Decimal: default precision 28 digits, default rounding ROUND_HALF_EVEN |
none built in (a TC39 Decimal proposal is not shipped); use integers or a library | decimal: 128-bit, base 10, 28-29 significant digits |
| Rounding | Decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) |
write it in integer math (above) | Math.Round(x, 2, MidpointRounding.AwayFromZero) |
| Display | f"{d:.2f}" on a Decimal, or babel for locales |
Intl.NumberFormat("en-US", { style: "currency", currency: "JPY" }) knows each currency’s decimals |
x.ToString("C", culture) |
| Trap to avoid | Decimal(0.1) copies the float error; write Decimal("0.1") |
0.1 + 0.2 !== 0.3; (1.005).toFixed(2) is "1.00" |
double and float for money; decimal from a double literal |
What it costs
Section titled “What it costs”| Time | Space | Notes | |
|---|---|---|---|
| Add / compare integer amounts | O(1) | O(1) | exact |
| Multiply by a rate, then round | O(1) | O(1) | the product can overflow 64-bit, or exceed 2^53 in a JS number |
Decimal / decimal arithmetic |
O(digits) | small | slower than integers (in Python, notably), still fine for business logic |
| Allocate among k parties | O(k log k) | O(k) | the sort of remainders; O(k) with a selection |
Overflow is the cost to watch. A ledger amount of 10^12 cents times a rate in parts per million is 10^18: it still fits in a 64-bit long (max about 9.2 × 10^18), but it is far beyond the 9 × 10^15 that a JavaScript number represents exactly. Use bigint in TypeScript, and say the bound out loud in C#.
Variations you’ll meet
Section titled “Variations you’ll meet”- Basis points and ppm. Rates arrive as integers to keep them exact: 290 bps is 2.90%, so a fee is
amount × 290 / 10_000. A rate of 920,000 ppm means ×0.92. Multiply before dividing, or you lose precision. - Round at the line or at the total. Tax rounded per line item and tax rounded once on the invoice total can differ by a cent or more. The spec (or the tax authority) decides. Ask.
- Sub-cent accumulation. Metered billing (per token, per API call) produces fractions of a cent per event. Accumulate in a finer integer unit, such as micro-cents, or as an exact fraction, and round only when a total is reported. Rounding every event to zero cents bills nothing.
- Currency conversion. A rate is directional (USD→EUR is not exactly 1 / EUR→USD, because dealers quote a spread). Round once, into the target currency’s minor unit, and never convert back and forth.
- Three-decimal and zero-decimal currencies. Payment providers add their own rules on top of ISO 4217. Stripe, for example, takes amounts in the smallest unit, treats JPY and KRW as zero-decimal, and asks that three-decimal amounts (BHD, KWD, JOD, OMR, TND) end in 0.
- Rounding in the customer’s favour. Some fee rules always round down (floor) so the customer is never overcharged. That’s a directed mode, not a tie rule.
Pitfalls
Section titled “Pitfalls”- Floats anywhere in the pipeline, including JSON parsing of large amounts,
parseFloaton input, or a* 0.029buried in a fee calculation. - Dividing before multiplying.
amount / 10_000 * bpsin integer math throws away everything below 10,000. Doamount * bps / 10_000. - Rounding more than once. Rounding a subtotal, then rounding again after tax (“double rounding”) can differ from a single rounding of the exact value.
- Assuming 2 decimals.
cents = dollars * 100breaks for JPY and BHD. Look up the exponent. - Losing the remainder on splits. Sum the parts in a test and assert that they equal the total.
- Mixing currencies.
usd + eurmust be an error. AMoneytype with a currency field makes this impossible to write by accident. - Negative zero and signs. In JS,
Math.round(-0.4)is-0, which prints as0but can compare oddly in strict tests. Normalize withx || 0orx + 0when the spec forbids-0.
In the interview
Section titled “In the interview”- Ask what unit amounts are in, which currencies appear, whether amounts can be negative, and how large they get.
- Ask for the rounding rule and the point at which to round (per item, per total, per report). If the spec names one, repeat it back.
- Say “I’ll keep everything in integer minor units and round once, here, with half-up away from zero.”
- Test a tie (
x.5), a negative tie, a zero amount, the largest amount, and a split that doesn’t divide evenly. Assert that splits sum to the total. - Likely follow-ups: add a second currency, change the rounding rule, pro-rate a partial refund, accumulate sub-cent costs, report totals per day without drift.
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”Classic exercises, not gym questions:
- Build a
Moneyvalue type: an integer amount plus a currency code, withadd,subtract,multiply(rate, rounding)andallocate(weights). Make adding different currencies a type error or an exception. - Price a shopping cart with tax applied per line and per invoice, and find a cart where the two differ.
- A loan amortization schedule: monthly interest rounded to the cent, where the final payment absorbs all the rounding so the balance ends at exactly 0.
- Split a restaurant bill where each person’s tip is proportional to what they ordered, and the parts must sum to the bill plus tip.
- Compound a savings balance monthly for 30 years, once with
Decimal/decimal/bigintand once with floats, and measure the difference.
Go deeper
Section titled “Go deeper”- David Goldberg, “What Every Computer Scientist Should Know About Floating-Point Arithmetic” (1991), the classic on why
0.1is inexact. - Python
decimalmodule documentation (contexts,quantize, rounding constants) and .NETMidpointRounding. - ISO 4217 currency codes for minor units, and Stripe’s currency docs on zero-decimal and three-decimal currencies.
- Martin Fowler, Patterns of Enterprise Application Architecture (2002), “Money” pattern, including
allocate.