Implementing a custom money class and currency exchange solution in Rails: a study
The maybe
codebase is a great
resource for Rails developers, and I’ve found several clever solutions in the
codebase such as a custom money
class instead of the use of the money
gem.
Its a great example of extending Ruby’s arithmetic operators, as well as a great
example of how to implement exchange rates in Ruby. Getting the most performance
out of Rails may include replacing gems with custom, simpler solutions that have
less layers of code, and this monetizable concern covers handles multiple types
of currency, converting one currency to another, and performing arithmetic and
multiple currency objects or even currency objects and numbers. This is all
without using gems such as money-rails
, which implements a similar method and
several other things such as a validator, migration extension, and a Money
serializer for ActiveJob
.
I’ve extracted out the code needed to make this work on your own project:
Monetizable
app/models/concerns/monetizable.rb
module Monetizableextend ActiveSupport::Concernclass_methods dodef monetize(*fields)fields.each do |field|define_method("#{field}_money") dovalue = self.send(field)value.nil? ? nil : Money.new(value, currency)endendendendend
To make performing arithmetic operations on money easy, let’s take a look at
money/arithmetic.rb
:
Money::Arithmetic
module Money::ArithmeticCoercedNumeric = Struct.new(:value)def +(other)if other.is_a?(Money)self.class.new(amount + other.amount, currency)elsevalue = other.is_a?(CoercedNumeric) ? other.value : otherself.class.new(amount + value, currency)endenddef -(other)if other.is_a?(Money)self.class.new(amount - other.amount, currency)elsevalue = other.is_a?(CoercedNumeric) ? other.value : otherself.class.new(amount - value, currency)endenddef -@self.class.new(-amount, currency)enddef *(other)raise TypeError, "Can't multiply Money by Money, use Numeric instead" if other.is_a?(self.class)value = other.is_a?(CoercedNumeric) ? other.value : otherself.class.new(amount * value, currency)enddef /(other)if other.is_a?(self.class)amount / other.amountelseraise TypeError, "can't divide Numeric by Money" if other.is_a?(CoercedNumeric)self.class.new(amount / other, currency)endenddef absself.class.new(amount.abs, currency)enddef zero?amount.zero?enddef negative?amount.negative?enddef positive?amount.positive?end# Override Ruby's coerce method so the order of operands doesn't matter# Wrap in Coerced so we can distinguish between Money and other typesdef coerce(other)[self, CoercedNumeric.new(other)]endend
Money::Currency
lib/money/currency.rb
# typed: falseclass Money::Currencyinclude Comparableclass UnknownCurrencyError < ArgumentErrorendCONFIG = Rails.root.join("config/currencies.yml")# Cached instances by iso code@@instances = {}class << selfdef new(code)iso_code = code.to_s.downcase@@instances[iso_code] ||= super(iso_code)enddef all@all ||= YAML.load_file(CONFIG)enddef all_instancesall.values.map { |currency_data| new(currency_data["iso_code"]) }enddef popularall.values.sort_by do |currency|currency["priority"]end.first(12).map { |currency_data| new(currency_data["iso_code"]) }endendattr_reader(:name,:priority,:iso_code,:iso_numeric,:html_code,:symbol,:minor_unit,:minor_unit_conversion,:smallest_denomination,:separator,:delimiter,:default_format,:default_precision)def initialize(iso_code)currency_data = self.class.all[iso_code]raise UnknownCurrencyError if currency_data.nil?@name = currency_data["name"]@priority = currency_data["priority"]@iso_code = currency_data["iso_code"]@iso_numeric = currency_data["iso_numeric"]@html_code = currency_data["html_code"]@symbol = currency_data["symbol"]@minor_unit = currency_data["minor_unit"]@minor_unit_conversion = currency_data["minor_unit_conversion"]@smallest_denomination = currency_data["smallest_denomination"]@separator = currency_data["separator"]@delimiter = currency_data["delimiter"]@default_format = currency_data["default_format"]enddef step(1.0 / (10**default_precision))enddef <=>(other)return nil unless other.is_a?(Money::Currency)@iso_code <=> other.iso_codeendend
Usage
As we can see, we just need a single class to define currencies, and while class variables violate the community ruby style guide, they made sense here as a global cache:
irb(main):002> cur = Money::Currency.new("usd")irb(main):003> cur.class.class_variable_get(:@@instances)#<Money::Currency:0x00000001299b87a8@default_format="%u%n",# ...
Here is a sample of config/currencies.yml
:
usd:name: United States Dollarpriority: 1iso_code: USDiso_numeric: "840"html_code: "$"symbol: "$"minor_unit: Centminor_unit_conversion: 100smallest_denomination: 1separator: "."delimiter: ","default_format: "%u%n"default_precision: 2eur:name: Europriority: 2iso_code: EURiso_numeric: "978"html_code: "€"symbol: "€"minor_unit: Centminor_unit_conversion: 100smallest_denomination: 1separator: ","delimiter: "."default_format: "%u%n"default_precision: 2
Testing out Monetizable
The monetizable
concern turns a column into a Money::Currency
backed number,
when referenced in Ruby, which in this example opens up the possibility for easy
arithmetic with other transactions, and a simple exchange rate implementation,
available as a method on a Currency
object. To test this out, perform the
following setup:
rails g model transaction amount:decimal{19-4} currency
. The maybe project uses a high precision for its decimal column, and this is probably because of 3rd party exchange rate services providing rates with high precision.- After running the migration, add the following code to the
Transaction
model:
class Transaction < ApplicationRecordinclude Monetizablemonetize :amountend
- Putting this all together, let’s test out the
Monetize
functionality:
irb(main):005> t = Transaction.new(amount: 10, currency: 'usd')=> #<Transaction:0x000000010f813c00 id: nil, amount: 0.1e2, currency: "usd", created_at: nil, updated_at: nil>irb(main):014> t.amount_money#<Money:0x000000010f41a540@amount=0.1e2,@currency=#<Money::Currency:0x000000010dc7a840@default_format="%u%n",@delimiter=",",@html_code="$",@iso_code="USD",@iso_numeric="840",@minor_unit="Cent",@minor_unit_conversion=100,@name="United States Dollar",@priority=1,@separator=".",@smallest_denomination=1,@symbol="$">,@errors=#<ActiveModel::Errors []>,@source=0.1e2,@validation_context=nil>irb(main):015>
As we can see, we now have multi-currency concept of money, with very fine decimal precision, and we can add, subtract, multiply, divide, and negate money using native ruby syntax:
irb(main):006> t.amount_money + 10=>#<Money:0x000000011633bd20@amount=0.2e2,@currency=#<Money::Currency:0x0000000116e9da38@default_format="%u%n",@delimiter=",",@html_code="$",@iso_code="USD",@iso_numeric="840",@minor_unit="Cent",@minor_unit_conversion=100,@name="United States Dollar",@priority=1,@separator=".",@smallest_denomination=1,@symbol="$">,@errors=#<ActiveModel::Errors []>,@source=0.2e2,@validation_context=nil>irb(main):007>irb(main):007> -t.amount_money=>#<Money:0x0000000117e9cdf8@amount=-0.1e2,@currency=#<Money::Currency:0x0000000116e9da38@default_format="%u%n",@delimiter=",",@html_code="$",@iso_code="USD",@iso_numeric="840",@minor_unit="Cent",@minor_unit_conversion=100,@name="United States Dollar",@priority=1,@separator=".",@smallest_denomination=1,@symbol="$">,@errors=#<ActiveModel::Errors []>,@source=-0.1e2,@validation_context=nil>
We can even perform arithmetic on two MoneyCurrency objects:
irb(main):008> t.amount_money + Transaction.new(amount: 20, currency: 'usd').amount_money=>#<Money:0x00000001161d40b8@amount=0.3e2,@currency=#<Money::Currency:0x0000000116e9da38@default_format="%u%n",@delimiter=",",@html_code="$",@iso_code="USD",@iso_numeric="840",@minor_unit="Cent",@minor_unit_conversion=100,@name="United States Dollar",@priority=1,@separator=".",@smallest_denomination=1,@symbol="$">,@errors=#<ActiveModel::Errors []>,@source=0.3e2,@validation_context=nil>irb(main):009>
Adding Exchange Rate Functionality
In the maybe app, they also have exchange rate functionality. Here is the
ExchangeRate
model so you can support this functionality as well. It’s pretty
cool to take a Money
object and call a method on it to turn it into another
currency:
Exchange Rate
class ExchangeRate < ApplicationRecordinclude Providedvalidates :base_currency, :converted_currency, presence: trueclass << selfdef find_rate(from:, to:, date:)find_by \base_currency: Money::Currency.new(from).iso_code,converted_currency: Money::Currency.new(to).iso_code,date: dateenddef find_rate_or_fetch(from:, to:, date:)find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:).tap(&:save!)enddef get_rate_series(from, to, date_range)where(base_currency: from, converted_currency: to, date: date_range).order(:date)endendend
and Provided
concern:
Back to top
module ExchangeRate::Providedextend ActiveSupport::Concernclass_methods doprivatedef fetch_rate_from_provider(from:, to:, date:)response = exchange_rates_provider.fetch_exchange_rate \from: Money::Currency.new(from).iso_code,to: Money::Currency.new(to).iso_code,date: dateif response.success?ExchangeRate.new \base_currency: from,converted_currency: to,rate: response.rate,date: dateelseraise response.errorendendendend