DEV Community

M Bellucci
M Bellucci

Posted on

Ruby Money & BigDecimal

The problem

In my current job, we faced calculation errors when operating with float for Money.
After some investigation, we found this article

Our first approach was to find every usage of money attributes and parse them with BigDecimal.

This solution has some drawbacks. First, we would need to replace it in many places. Second, it doesn't prevent future developers to use float.

In order to overcome those issues, I wanted to enforce a validation over every money attribute.
Then if a future execution accidentally does money_attr = 233.0 (float) we could detect that error and report it.
After thinking for a moment I thought that would be preferable to do a conversion (float->BigDecimal) rather than raising an error.

So I'd like to write Ruby code to say: "hey if someone tries to assign a float to a money attribute then convert it to BigDecimal"

The Solution

In order to do that I came up with this solution:

module BigDecimalCheck
  def self.included(klass)
    klass.extend(ClassMethods)
  end

  module ClassMethods
    def enforce_big_decimal(*attrs)
      attrs.each do |attr|
        define_method :"#{attr}=" do |value|
          # try to convert argument to BigDecimal
          instance_variable_set(:"@#{attr}", BigDecimal(value.to_s))
        end
      end
    end
  end
end

class Rate
  attr_accessor :money

  include BigDecimalCheck
  enforce_big_decimal :money
end
Enter fullscreen mode Exit fullscreen mode

With that code in place a consumer code would work like this

r = Rate.new
r.money = 33            # works
r.money = 33.0293       # works
r.money = "33"          # works
r.money = "33.0293"     # works
r.money = "no numeric"  # Argument Error

Enter fullscreen mode Exit fullscreen mode

How this solution work?

  • self.included it is a hook that Ruby modules provide. It is called when the module is included and receives the class that included it.

  • klass.extend(ClassMethod) Let's say that klass = Foo, then this would be the same as doing:

class Foo
  extend ClassMethod
  # Now I'm able to call methods in ClassMethod form here
end
Enter fullscreen mode Exit fullscreen mode

which will inject methods from ClassMethod into Foo object at class scope.

  • enforce_big_decimal
    def enforce_big_decimal(*attrs)
      attrs.each do |attr|
        define_method :"#{attr}=" do |value|
          # try to convert argument to BigDecimal
          instance_variable_set(:"@#{attr}", BigDecimal(value.to_s))
        end
      end
    end
Enter fullscreen mode Exit fullscreen mode

If I call enforce_big_decimal :unit_price, total_price
It will define two methods:

def unit_price=(value)
  parsed_value = BigDecimal(value.to_s) # raise error if cannot parse
  instance_variable_set(:@unit_price, parsed_value)
end
def total_price=(value)
  parsed_value = BigDecimal(value.to_s) # raise error if cannot parse
  instance_variable_set(:@total_price, parsed_value)
end
Enter fullscreen mode Exit fullscreen mode

Conslusion

I've shown an example of how to generalize the solution of a problem by using ruby meta-programming techniques.

I hope it can help you solve similar problems.

Feel free to ask questions or suggest improvements.

Thanks for reading!

Top comments (0)