#08Money

Subscribe to our episode notifications

* indicates required

Intuit Mailchimp

Introduction

We want to write a small system to track our personal finances. Nothing fancy, we just want to register incomes and expenses, retrieve the total income and expenses and finally, the net income for the month.

application = PersonalAccounting.new(
  incomes: incomes,
  expenses: expenses,
)

application.total_income         # => 4500
application.total_expenses       # => 4350

application.net_income           # => 150

And also want to see a formatted report of incomes and expenses so we have a clearer view.

application.income_report
application.expense_report

# >> Incomes:
# >> |                    Salary | 3500.00 USD |
# >> |        Freelance Projects | 1000.00 USD |
# >>
# >> Expenses:
# >> |             Rent/Mortgage | 1200.00 USD |
# >> |                 Utilities |  150.00 USD |
# >> |        Internet and Phone |  100.00 USD |
# >> |                 Groceries |  400.00 USD |
# >> |                Dining Out |  250.00 USD |
# >> |               Car Payment |  350.00 USD |
# >> |             Car Insurance |  100.00 USD |
# >> |                  Gasoline |  150.00 USD |
# >> |          Health Insurance |  300.00 USD |
# >> |        Streaming Services |   25.00 USD |
# >> |      Credit Card Payments |  200.00 USD |
# >> |     Savings & Investments |  500.00 USD |
# >> |  Clothing & Personal Care |  100.00 USD |
# >> |                 Education |   50.00 USD |
# >> |               Travel Fund |  100.00 USD |
# >> |            Emergency Fund |  200.00 USD |
# >> |             Entertainment |   75.00 USD |
# >> |             Miscellaneous |  100.00 USD |
# >>

So far, so good. Our tiny system meets our needs and we're happy with it.

Until one day we discover a small bug: We bought a mouse and a keyboard,

expenses = [
  # ...
  ["New mouse", 7.1, "USD"],
  ["New keyboard", 13.2, "USD"]
]

but when we see our net income, we find there's a rounding error.

application.net_income           # => 129.69999999999982

We also decide that we want to start including both incomes and expenses in other currencies since, so far, we've been converting everything to US Dollars by hand in our raw data.

incomes = [
  ["Salary", 3500, "USD"],
  ["Freelance Projects", 1_000, "USD"],
  ["Sell something", 10_000, "ARS"],
  # ["Sell something", 10, "USD"],
]

Introducing Money

To get rid of both of these issues, we can use the money gem.

$ gem install money

The money gem provides a set of objects to deal with working with currencies.

We can create a Money object by calling the from_amount method on the Money class. And we also need to specify a currency.

Money.from_amount(5, "USD")
# => #<Money fractional:500 currency:USD>

Before moving any further, lets get rid of this warning. What it says is that we need to decide what's our strategy for dealing with decimals,

To deal with rounding issues, we have a few options: We can round down, where we always go to the nearest lower whole number. Round up, where we always go to the nearest higuer whole number. Or use the standard rounding, where we go closest whole number.

Money.rounding_mode = BigDecimal::ROUND_DOWN
Money.rounding_mode = BigDecimal::ROUND_UP
Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN

Now that the rounding issue is out of our way,

require "money"

Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN

money = Money.from_amount(5, "USD")
# => #<Money fractional:500 currency:USD>

Lets see what we actually got there.

As we can observe, this Money object saves the amount in cents on it's cents attribute and it's currency as an instance of Money::Currency on it's currency attribute.

money.cents    # => 500
money.currency # => #<Money::Currency id: usd, priority: 1, symbol_first: true, thousands_separator: ,, html_entity: $, decimal_mark: ., name: United States Dollar, symbol: $, subunit_to_unit: 100, exponent: 2, iso_code: USD, iso_numeric: 840, subunit: Cent, smallest_denomination: 1, format: >

Note that the amount is held as a Fractional number, which represents a mathematical fraction and provide more precise calculations. This helps us to automatically handle and prevent floting point error calculations.

money = Money.from_amount(5, "USD")
# => #<Money fractional:500 currency:USD>

Implementing Reports using Money

With this knowledge, we can start by implementing the Reports feature. We start by requiring the gem and configuring our rounding mode to use standard rounding.

require "money"

Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN

Then, on our PersonalAccounting's initializer, we convert the amounts for both the incomes and the outcomes to a Money object, using the second element of the array as the amount and the third as the currency.

class PersonalAccounting
  attr_reader :incomes
  attr_reader :expenses

  def initialize(incomes:, expenses:)
    @incomes = incomes.map { |income_array|
      Transaction.new(
        description: income_array[0],
        amount:      Money.from_amount(income_array[1], income_array[2]),
      )
    }
    @expenses = expenses.map { |expense_array|
      Transaction.new(
        description: expense_array[0],
        amount:      Money.from_amount(expense_array[1], expense_array[2]),
      )
    }
  end

If we run our income report, we see that the item where we sold something in Argentinian pesos is expressed in US dollars, but it's amount is still expressed in Pesos, which is completely wrong.

application.income_report

# >> Incomes:
# >> |                    Salary |  3500.00 USD |
# >> |        Freelance Projects |  1000.00 USD |
# >> |            Sell something | 10000.00 USD |
# >>

And that's because in the report_for method, we're taking the amount as is and hardcoding the currency.

def report_for(movements)
  movements.each do |movement|
    puts format("| %25s | %8.2f USD |", movement.description, movement.amount)
  end
end

What we want here is to keep the amount and change the currency to be the one we set on the currency method.

# ...
amount:      Money.from_amount(income_array[1], income_array[2]),
# ...

To do so, in our Transaction class, we need to expose the amount as the instance variable's amount and the currency as the variable's currency.

class  Transaction
  attr_reader :description

  def initialize(description:, amount:)
    @description = description
    @amount = amount
  end

  def amount
    @amount.amount
  end

  def currency
    @amount.currency
  end
end

And maybe we can change that variable name to be called @money.

class  Transaction
  attr_reader :description

  def initialize(description:, amount:)
    @description = description
    @money = amount
  end

  def amount
    @money.amount
  end

  def currency
    @money.currency
  end
end

And then, on the report_for method, we replace the hardcoded currency with the movement's currency.

def report_for(movements)
  movements.each do |movement|
    puts format("| %25s | %8.2f %s |", movement.description, movement.amount, movement.currency)
  end
end

And now we can see the proper amount and currency showing up in our report.

application.income_report

# >> Incomes:
# >> |                    Salary |  3500.00 USD |
# >> |        Freelance Projects |  1000.00 USD |
# >> |            Sell something | 10000.00 ARS |
# >>

We can also change our code to use the format method on Money objects. But first, we need to expose it. We'll just create a format_amount method that returns @money.format.

class  Transaction
  # ...

  def format_amount
    @money.format
  end
end

And change our report to use that.

def report_for(movements)
  movements.each do |movement|
    puts format("| %25s | %s |", movement.description, movement.format_amount)
  end
end

And try it out. And that doesn't work. We're getting an error caused by a deprecation.

Using the current versions of the money gem, we must specify the locales for our Money objects, since it doesn't infer it itself.

application.income_report

# >> Incomes:

# !> [DEPRECATION] You are using the default localization behaviour that will change in the next major release. Find out more - https://github.com/RubyMoney/money#deprecation

# ~> I18n::InvalidLocale
# ~> :en is not a valid locale
# ~>
# ...
# ~> /tmp/seeing_is_believing_temp_dir20240324-64962-p6g002/program.rb:116:in `<main>'

This is an easy change to make.

We just need to specify which locales backend we'll use, lets set it to :i18n, since is the most commonly used and will integrate very well with Rails.

And then set the locale we want to use, in our case we'll use English.

Money.locale_backend = :i18n
I18n.config.available_locales = :en

And now, when we try it again, it works as expected.

Note that the Argentinian pesos symbol is the same as the US dolar symbol.

application.income_report

# >> Incomes:
# >> |                    Salary |  $3500.00 |
# >> |        Freelance Projects |  $1000.00 |
# >> |            Sell something | $10000.00 |
# >>

If we changed it to other currency this would change.

Money.from_amount(100, "EUR").format # => "€100.00"
Money.from_amount(100, "GBP").format # => "£100.00"
Money.from_amount(100, "USD").format # => "$100.00"
Money.from_amount(100, "JPY").format # => "¥100"

Since we're here, I should say that we could express the currencies in several ways.

We could use a lower-case symbol or string.

We could also pass in a Money::Currency instance with the currency symbol expressed as both a string or a symbol. and it would work the same way.

Money.from_amount(100, :eur).format                       # => "€100.00"
Money.from_amount(100, "gbp").format                      # => "£100.00"
Money.from_amount(100, Money::Currency.new("USD")).format # => "$100.00"
Money.from_amount(100, Money::Currency.new(:jpy)).format  # => "¥100"

And in case we need to use a currency and we don't know the exact currency three-letter code, we can see all the available currencies with by calling the Money::Currency.all method. Or get just the ids.

Money::Currency.all
# => [#<Money::Currency id: usd, priority: 1, symbol_first: true, thousands_separator: ,, html_entity: $, decimal_mark: ., name: United States Dollar, symbol: $, subunit_to_unit: 100, exponent: 2, iso_code: USD, iso_numeric: 840, subunit: Cent, smallest_denomination: 1, format: >,
#     #<Money::Currency id: eur, priority: 2, symbol_first: true, thousands_separator: ., html_entity:
# ...
Money::Currency.all.map(&:id)
# => [:usd,
#     :eur,
#     :gbp,
#     :aud,
#     :cad,
#     :jpy,
#     :byr,
#     :aed,
#     :afn,
#     :all,
#     :amd,
#     :ang,
# ...

Implement totals and nets

Now that we can see the reports, we can try out our totals.

That doesn't seem right.

application = PersonalAccounting.new(incomes: incomes, expenses: expenses)

application.total_income   # => 0.145e5
application.total_expenses # => 0.43703e4
application.net_income     # => 0.101297e5

Lets expose our Money object in the Transaction class.

class Transaction
  attr_reader :description
  attr_reader :money

  def initialize(description:, amount:)
    @description = description
    @money = amount
  end

  # ...
end

And use that to get our totals.

def total_income
  incomes.map(&:money).sum
end

def total_expenses
  expenses.map(&:money).sum
end

This gives us a different error that says it can't convert from Argentinian Pesos to US Dollars.

application.total_income   # =>
application.total_expenses # =>
application.net_income     # =>

# ~> Money::Bank::UnknownRate
# ~> No conversion rate known for 'ARS' -> 'USD'
# ~>
# ~> /home/fedex/.rvm/gems/ruby-3.2.2/gems/money-6.19.0/lib/money/bank/variable_exchange.rb:122:in `exchange_with'
# ~> /home/fedex/.rvm/gems/ruby-3.2.2/gems/money-6.19.0/lib/money/money.rb:537:in `exchange_to'
# ~> /home/fedex/.rvm/gems/ruby-3.2.2/gems/money-6.19.0/lib/money/money/arithmetic.rb:138:in `block (2 levels) in <module:Arithmetic>'
# ~> /tmp/seeing_is_believing_temp_dir20240324-78811-731knt/program.rb:91:in `sum'
# ~> /tmp/seeing_is_believing_temp_dir20240324-78811-731knt/program.rb:91:in `total_income'
# ~> /tmp/seeing_is_believing_temp_dir20240324-78811-731knt/program.rb:121:in `<main>'

We'll deal with this in a second, but if we comment out our Pesos transaction,

incomes = [
  ["Salary", 3500, "USD"],
  ["Freelance Projects", 1_000, "USD"],
  #["Sell something", 10_000, "ARS"],
]

And try again, it now just works.

application.total_income   # => #<Money fractional:450000 currency:USD>
application.total_expenses # => #<Money fractional:437030 currency:USD>
application.net_income     # => #<Money fractional:12970 currency:USD>

Dealing with currency conversions

We can now operate accurately in Dollars. But what about other currencies?

Well, as in real life, in order to operate between currencies, we need to exchange from one to the other. And to do so, we need to know the conversion rates. Money works in the same way.

In order to add currency conversion rates, we use the Money#add_rate class method specifying our origin currency, our destination currency and the rate.

And we'll add the inverse for good messure, since Money doesn't infer it.

Money.add_rate("USD", "ARS", 1_000)
Money.add_rate("ARS", "USD", 1.0/1_000)

Now we can exchange money both from Pesos to Dollars and viceversa.

Money.from_amount(10, "USD").exchange_to("ARS").format # => "$10000.00"
Money.from_amount(10, "ARS").exchange_to("USD").format # => "$0.01"

Lets apply this knowledge to our totals.

We set our exchange rates.

Money.add_rate("USD", "ARS", 1_000)
Money.add_rate("ARS", "USD", 1.0/1_000)

And then, when we perform the sum of the incomes and expenses, we need to exchange everything to US Dollars

def total_income
  incomes.sum { |transaction|
    transaction.money.exchange_to("USD")
  }
end

def total_expenses
  expenses.sum { |transaction|
    transaction.money.exchange_to("USD")
  }
end

Now we can uncomment our Pesos income.

And when we tryit out it now works as we expect it to.

application.total_income   # => #<Money fractional:1000450000 currency:USD>
application.total_expenses # => #<Money fractional:437030 currency:USD>
application.net_income     # => #<Money fractional:1000012970 currency:USD>

Conclusion

Whenever we're dealing with measures, it's always recommended to have specialized measure objects instead of working with primitive values.

Utilizing appropriate objects ensures that we are working with the correct units, enhancing the accuracy and reliability of our computations. Moreover, it facilitates safe and error-free conversion between different units, further increasing the robustness of our operations.

The Money gem provides us these specialized objects for dealing with, well, money. It's easy to install and, with very little configuration, we can have it working for us.

It also provides a lot of currencies to work with, including some crypto currencies.

I highly recommend giving it a try.

I hope you enjoyed this episode and I'll see you on the next one.

Subscribe to our episode notifications

* indicates required

Intuit Mailchimp

Comments