DEV Community

SEN LLC
SEN LLC

Posted on

Why 1100 / 1.1 = 999.9999… Is a Tax Calculator Bug, and Two Ways to Fix It

Why 1100 / 1.1 = 999.9999… Is a Tax Calculator Bug, and Two Ways to Fix It

Japan has a 10% consumption tax (8% for groceries and newspapers). Your naive JavaScript calculator goes 1100 / 1.1 → 999.9999999999999 → Math.floor → 999 and produces a tax receipt that's off by one yen, which is the kind of thing you cannot ship. I hit the same bug writing a tax calculator for Japanese prices, and it became a tour through floating-point gotchas and the "per-item round vs. per-rate sum" accounting choice.

Calculating tax sounds trivial until you hit IEEE 754. "What's 1100 yen pre-tax at 10%?" should be 1000 yen. What JavaScript actually returns:

1100 / 1.1  // 999.9999999999999
Enter fullscreen mode Exit fullscreen mode

Floor that and you get 999, which means the tax breakdown on a receipt is off by one. For real currency math that's unshippable.

🔗 Live demo: https://sen.ltd/portfolio/shohizei-calc/
📦 GitHub: https://github.com/sen-ltd/shohizei-calc

Screenshot

Toggle between 10% (standard) and 8% (reduced — applies to groceries and newspapers under Japanese tax law), bidirectional conversion (pre-tax ↔ post-tax), three rounding modes. The library side (computeCart) additionally handles mixed-rate carts with both accounting methods.

Vanilla JS, zero deps, ~80 lines of logic, 12 tests.

The floating-point trap when going post-tax → pre-tax

The naive removeTax implementation:

const exclTax = Math.floor(priceInclTax / (1 + rate))
// priceInclTax = 1100, rate = 0.1 → 999.9999... → 999 ❌
Enter fullscreen mode Exit fullscreen mode

0.1 can't be represented exactly in binary floating point (it's a repeating fraction in base 2, just like 1/3 is in base 10). So 1 + 0.1 is 1.1000000000000001, and 1100 / 1.1 becomes 999.9999999999999. Floor that and you've lost a yen.

The minimal fix: add a tiny epsilon before rounding.

export function removeTax(priceInclTax, rate = 0.1, rounding = 'floor') {
  const exact = priceInclTax / (1 + rate)
  const exclTax = round(exact + 1e-9, rounding)
  return {
    exclTax,
    taxAmount: priceInclTax - exclTax,
    inclTax: priceInclTax,
    rate,
  }
}
Enter fullscreen mode Exit fullscreen mode

1e-9 is one nanoyen — negligible for any realistic consumer price range (0 to 10⁸ yen). Adding it shifts 999.9999999999999 up to 1000.0000000009, which floors to 1000, the mathematically correct answer.

The "correct" solution is arbitrary-precision arithmetic (BigDecimal or similar), but that means pulling in a dependency for a tool that otherwise fits in 80 lines. For the ranges this calculator handles, the epsilon is a pragmatic and safe fix. It's also a nice inversion of the usual epsilon-comparison pattern: instead of saying "these two floats are equal enough," we're saying "bump this float into the correct integer bucket."

"Per-item round" vs "per-rate sum" accounting

For a cart with multiple items, there are two defensible ways to compute the total tax, and the Japanese National Tax Agency explicitly allows merchants to pick either:

Method A — round per item, then sum

let subtotalA = 0
let taxA = 0
for (const item of items) {
  const r = addTax(item.price, item.rate ?? 0.1, rounding)
  subtotalA += r.exclTax
  taxA += r.taxAmount
}
Enter fullscreen mode Exit fullscreen mode

Method B — group by rate, sum pre-tax, round once

const groups = new Map()
for (const item of items) {
  const rate = item.rate ?? 0.1
  groups.set(rate, (groups.get(rate) ?? 0) + item.price)
}
let subtotalB = 0
let taxB = 0
for (const [rate, sum] of groups) {
  subtotalB += sum
  taxB += round(sum * rate, rounding)
}
Enter fullscreen mode Exit fullscreen mode

When do they differ? Whenever per-item tax has a non-integer rounding remainder that accumulates. Example: three items at 105 yen each, 10% tax, floor rounding.

  • Method A: each item's tax is 10.5 → floor → 10, so 3 × (105 + 10) = 345
  • Method B: pre-tax sum is 315, tax is 31.5 → floor → 31, so 315 + 31 = 346

One yen difference, per rounding boundary, accumulated over thousands of line items, has measurable impact on a merchant's quarterly filing. Pick one method and stay consistent.

Returning the diff explicitly

Showing both methods without an explanation leaves the user wondering which to trust. Return the difference so it's visible at a glance:

return {
  methodA: { subtotal: subtotalA, tax: taxA, total: subtotalA + taxA },
  methodB: { subtotal: subtotalB, tax: taxB, total: subtotalB + taxB },
  diff: (subtotalA + taxA) - (subtotalB + taxB),
}
Enter fullscreen mode Exit fullscreen mode

diff === 0 means both methods agree (usually the case for single-rate carts). Positive means A is higher; negative means B is higher. The UI highlights the diff row when non-zero so the user immediately notices that their accounting method choice matters for this cart.

Tiny round helper

Three rounding modes in one function:

function round(n, mode) {
  if (mode === 'ceil') return Math.ceil(n)
  if (mode === 'round') return Math.round(n)
  return Math.floor(n)  // default
}
Enter fullscreen mode Exit fullscreen mode

Floor is the default because it's the most common in Japanese retail practice (it favors the customer on fractional yen). Round and ceiling are available for merchants who've chosen those methods.

Tests

12 cases on node --test, with the critical regression-guard being:

test('addTax 1000 at 10% = 1100', () => {
  const r = addTax(1000, 0.1)
  assert.equal(r.inclTax, 1100)
})

test('removeTax 1100 at 10% = 1000 (float safe)', () => {
  const r = removeTax(1100, 0.1)
  assert.equal(r.exclTax, 1000)  // NOT 999
})

test('removeTax 108 at 8% = 100', () => {
  const r = removeTax(108, 0.08)
  assert.equal(r.exclTax, 100)
})

test('cart with single rate: A and B match', () => {
  const r = computeCart([
    { price: 1000, rate: 0.1 },
    { price: 2000, rate: 0.1 },
  ])
  assert.equal(r.diff, 0)
})
Enter fullscreen mode Exit fullscreen mode

The 1100 → 1000 test is load-bearing. Anyone who looks at + 1e-9 in the source and thinks "this looks weird, I bet I can remove it" gets a failing test immediately. That's the point of the test — it's there specifically to protect against a plausible future "cleanup."

Series

This is entry #13 in my 100+ public portfolio series.

"Shōhizei" (消費税) is the Japanese term for consumption / value-added tax.

Top comments (1)

Collapse
 
sklieren profile image
Ben K. • Edited

I solved the same issue in a different way: Eliminate divisions with floating points.

Instead of c=a/b, I do c = div(a, b); with div = (a, b) => (a*e)/(b*e);
and e=1e2, which describes the precision I need.
While this also adds a new abstraction layer, it's safe to use and easy to understand.