money: Unexpected result when doing arithmetic on Money objects

204.87 + (204.87/100.0) * 22.4 results in 250.76088000000001

Money.new(20487).to_f + (Money.new(20487).to_f/100.0) * 22.4 results in 250.76088000000001

Money.new(20487) + (Money.new(20487)/100.0) * 22.4 results in #<Money fractional:25079 currency:GBP>

I would expect it to be #<Money fractional:25076 currency:GBP> is this a bug?

About this issue

  • Original URL
  • State: closed
  • Created 8 years ago
  • Comments: 18 (13 by maintainers)

Commits related to this issue

Most upvoted comments

@kernow - This is a classic rounding loss scenario. Anytime you multiple or divide Money objects, ESPECIALLY with odd dollar or cent values or decimal rate factors (APRs, Yields, etc…) you can encounter loss of precision. To avoid this, you have to add room on your Money objects for the fractional cents (i.e. subcents). I’ve done this in the past by defining a custom currency with room for 8 subcents as opposed to the usual 2.

  config.register_currency = {
    :priority            => 1,
    :iso_code            => :us8,
    :name                => "US Dollar with subunit of 8 digits",
    :symbol              => "$",
    :symbol_first        => true,
    :subunit             => "Subcent",
    :subunit_to_unit     => 100000000,
    :thousands_separator => ",",
    :decimal_mark        => "."
  }

  config.add_rate "USD", "US8", 1
  config.add_rate "US8", "USD", 1

The pegged 1:1 exchange rate is key. This is how you round off the fractional cents at the end to get to the value you’re looking for.

  204.87.to_money(:us8) + (204.87.to_money(:us8)/100) * 22.4

Results in a precise value #<Money fractional:25076088000 currency:US8>

In order to get the rounded value, exchange back to your base currency.

  precise_value.exchange_to(:usd)

=> #<Money fractional:25076 currency:USD>

In short, this isn’t a bug. money makes no assumptions about how to round. If you need this level of precision, I recommend defining a custom currency to carry the fractional cents.

@antstorm - I think I’ve answered this completely and you can close this issue?

@antstorm I think I have to 🙂

I’m thinking about all this stuff right now, and it looks like proper guidance is the most important thing here, because whatever we implement, it won’t solve most problems anyway.

Because we calculate the money in order to store them somewhere most likely. And we are supposed to store rounded money, the ones that are finalized and do make sense, right?

So, the only actually “dangerous” operations are division and multiplication (as we can multiply by a fractional number). One must avoid them as much as possible.

For example, let’s say we have to split some uneven amount like $9.99 into two separate ones. Let’s say it should be split in a ratio of 75% and 25%. We can do it like so:

payment = Money.new(9_99)

a = payment * 0.75
# => #<Money fractional:749 currency:USD>
b = payment * 0.25
# => #<Money fractional:250 currency:USD>

a + b == payment
# => true

So far so good with default rounding mode (749.25 is rounded down, and 249.75 is rounded up). ! Your rounding mode could be different though ! Also 50/50% split is a corner case:

payment = Money.new(9_99)

a = payment * 0.50
# => #<Money fractional:500 currency:USD>
b = payment * 0.50
# => #<Money fractional:500 currency:USD>

a + b == payment
#=> false

Whoops! We got an error here! So what could we do in order to avoid it? Avoid multiplication. So we should have this instead:

payment = Money.new(9_99)

a = payment * 0.50
# => #<Money fractional:500 currency:USD>
b = payment - a
# => #<Money fractional:499 currency:USD>

a + b == payment
#=> true

That’s better.

One might think an error is too small to worry about. Well, maybe in some cases it’s OK. But what if you have validations? Like “a + b must be equal to payment” for example? The code might fail it’s own validations.

So one must be sure the validations are being done in the same way the calculations are. Or maybe add some delta to them? Or maybe just get rid of them? 😉

@danielricecodes, I guess I didn’t mean to avoid them all the time, but in some cases, like splitting 😃 And it turns out we got that sweet Money#allocate, thanks @antstorm! We definitely have to advise people on using it!

@FunkyloverOne that makes sense, however it’s not always trivial, imagine if you need to split money 3-way, or 100-way? Have you looked at Money#allocate? I think we should advice on using it when dealing with the use-case you’ve described.

But yeah, that problem definitely exists as we’re resetting the error every step of the way. infinite_precision might be another option here