Mustafa Nevfel

Snippet · TypeScript

Store money as integer minor units and round tax per line or document

Do the arithmetic in bigint minor units with an explicit tie rule. Five 1.10 lines at 8% tax to 0.45 per line or 0.44 per document, so store the scope.

Store money as bigint minor units with the currency and its exponent, and divide in one function with an explicit tie rule. Rounding scope changes the result: five lines of 1.10 taxed at 8% give 0.45 per line and 0.44 per document. Neither is wrong, so keep scope and mode in the rule set.

ts
type Mode = "half_even" | "half_up";

function divRound(n: bigint, d: bigint, mode: Mode): bigint {
  if (n < 0n || d <= 0n) throw new RangeError("divRound domain");
  const q = n / d;
  const twice = (n % d) * 2n;
  if (twice < d) return q;
  if (twice > d) return q + 1n;
  return mode === "half_up" || q % 2n === 1n ? q + 1n : q; // tie
}

const sum = (xs: bigint[]) => xs.reduce((s, x) => s + x, 0n);
const tax = (netMinor: bigint, taxRateBp: bigint, mode: Mode) =>
  divRound(netMinor * taxRateBp, 10000n, mode);

const a = [110n, 110n, 110n, 110n, 110n]; // five lines of 1.10, 8% = 800 bp
sum(a.map((n) => tax(n, 800n, "half_even"))); // 45n per line
tax(sum(a), 800n, "half_even");               // 44n per document

const b = [105n, 115n, 125n, 135n];           // 10% = 1000 bp, every line a tie
sum(b.map((n) => tax(n, 1000n, "half_up")));   // 50n
sum(b.map((n) => tax(n, 1000n, "half_even"))); // 48n, the exact tax on 480

Why does it work?

  • Floats cannot see a tie: Math.round(1.005 * 100) returns 100, because the product is 100.49999999999999; half-up on the decimal gives 101.
  • Half-up pushes every tie the same way, so error accumulates; half-even sends a tie to the even neighbor and cancels on average: 0.48, the exact tax, against 0.50.
  • "Per document" means per rate: sum net by rate, round once. Scope and mode are rule-set columns that each document points at.
  • ISO 4217 exponents differ: USD 2, JPY 0, KWD 3. Keep the exponent on the row (ISO changed CLF from 0 to 4 in 2014) and reject input with extra decimals instead of rounding it.

When should you not use it?

  • Unit prices are whole minor units here; 1.459 per liter needs a scale column.
  • Do not pick scope or mode by taste: the market or the customer's system expects one.
  • JSON.stringify throws on bigint, so store and send amounts as strings.