Mesrai
Back to blog
// essayTechnical Deep Dive

Float Arithmetic in Currency: The Refund Rounding Bug Mesrai Saved

Real PR catch: refund using float math — drift compounds over transactions. Integer-paise fix.

Mesrai TeamAugust 20, 20268 min read

A refund calculation using JavaScript floats. Author multiplied original amount by tax rate. Off by paisa on most calculations. Compounded over 10K transactions, the error is real money owed or kept.

The vulnerable diff

tsbilling/refund.ts
// billing/refund.ts
function calculateRefund(originalRupees: number, taxRate: number) {
  // BUG: float math on money
  const refund = originalRupees * taxRate;   // 0.1 * 0.2 ≠ 0.02
  return refund;
}

// 0.1 + 0.2  === 0.30000000000000004
// 1149.99 * 0.18 === 206.99820000000003
// On 10K refunds: cumulative error in the thousands of paise.

What is wrong

IEEE 754 double-precision floats can exactly represent powers of 2 but not most decimal fractions. `0.1` is a repeating binary; `0.1 + 0.2 = 0.30000000000000004`. For currency, every multiplication and addition introduces tiny errors that compound. The standard pattern is to store and operate on integer minor-units (paise, cents) throughout the system, and only convert to a major-unit string at display time. Alternative: a decimal library (big.js, decimal.js) for non-integer cases.

The attack

Symptom: refunds off by paisa, customer complaints, accounting reconciliation requires a fudge factor.

textexploit
# Direct: 1149.99 * 0.18 → 206.9982...
# Rounded to 2dp: 207.00 (looks right)
# 10,000 refunds at this pattern:
# Cumulative drift: ~₹18 due to rounding-direction asymmetry.
# Over the year: easily ₹2,000+ unreconciled.

Banks audit this. Reconciliation pipelines flag it.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Logic] [Float-Money] [critical]

Float arithmetic on currency. Use integer minor-units (paise):

  function calculateRefundPaise(originalPaise: number, taxBp: number): number {
    // taxBp = tax in basis points (18% = 1800 bp). Integer math throughout.
    return Math.round(originalPaise * taxBp / 10_000);
  }

  // Display:
  const display = (paise / 100).toFixed(2);  // OK for display only

For non-tax cases where percentages are exact decimals, a decimal
library (big.js / decimal.js) is fine too:

  import Big from "big.js";
  const refund = new Big(originalRupees).times(0.18).round(2);

Whichever you pick, never mix float math with currency in business
logic. Storage = integer minor-units. Computation = integer or
decimal. Display = formatted string.

The fix

tsbilling/refund.ts (fixed)
// billing/refund.ts — fixed (integer paise)
function calculateRefundPaise(originalPaise: number, taxBp: number): number {
  // taxBp = basis points (18% = 1800 bp). Math.round on division.
  return Math.round((originalPaise * taxBp) / 10_000);
}

// Display helper:
function formatRupees(paise: number): string {
  return (paise / 100).toFixed(2);
}

Store paise. Multiply by basis points (no decimal). Divide last with `Math.round`. The arithmetic stays in integer space until the final rounded result.

Why human review missed it

Float-on-money is one of those bugs that looks correct in tests with small numbers — the rounding error is invisible at single-transaction scale. The bug compounds at scale. Mesrai catches every arithmetic operation on a variable identified as currency (named `amount`, `price`, `total`, `refund` etc., or typed as Money).

Related rules + further reading

Mesrai rule pack: logic/no-float-on-currency — flags float arithmetic on currency variables.

Martin Fowler: Money pattern.

Mike Cohen: Avoiding the float-rounding trap on currency.

Takeaway

Integer paise throughout. Float never touches money. Display only at the boundary. Mesrai catches the gap.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.