DEV Community

Cover image for Keep Your Ruby Code Maintainable with Money-Rails
Pulkit Goyal for AppSignal

Posted on • Originally published at blog.appsignal.com

Keep Your Ruby Code Maintainable with Money-Rails

When working with money in an application, ensuring everything is accounted for is important.

In this post, we will explore some common methods and best practices of handling money in your Ruby app, and see how you can use money-rails to write maintainable money-handling code.

Let's get started!

Use Cases for Storing Money

There are several use cases where your Ruby app might need to handle money — for example, an e-commerce company that sells products, a SaaS maintaining users' subscriptions, etc.

In this post, we will walk through some examples of how to handle money in an e-commerce app.

Storing Money in a Ruby Database

First, let’s start with strategies for storing money in your database: using a float column, the numeric type, and the integer type.

Using a Float Column

The most obvious way to store money is to use a float column. However, because of the way floating-point numbers are stored, they do not guarantee exactness. Instead, they only approximate the actual value. While this might be sufficient for some applications, there are much better ways to store money.

Using the numeric Type

The numeric type can store arbitrary-precision numbers in a database.

A numeric column can store a large range of numbers (up to 131,072 digits before a decimal point and up to 16,383 digits after a decimal point) and is therefore perfectly suited to work with money.

But one important point to note here is that calculations on numeric types are very slow compared to integer/floating-point numbers.

Numeric types should be the go-to data type when you need arbitrary-precision numbers, but, depending on business logic, they are rarely needed unless it's for very specific use cases. Let’s see if we can do better in an e-commerce scenario.

Using the integer Type

We need to store the prices of products, with the lowest unit being cents. A product can be priced at $19.99 but never $19.9954321. This means that, instead of working at the dollar level, we will always have an integer value as the price at the cents level. We can easily store an integer without any approximation in the database using one of the integer types (e.g., smallint, integer, bigint).

But please note that this does constrain the represented values (as compared to the numeric type) — the maximum integer value on Postgres 2147483647 will be $21,474,836.47. This should normally be enough for any e-commerce product, but it’s good to know that there’s a limit. If you require higher precision (for example, a 10th of a cent), this is possible by just storing the amount as a mill instead of cents. Keep in mind that this will further decrease the range of values that can be represented in an integer column.

But an integer's big advantage over a numeric type is that calculations are very fast. As long as you can work within the integer (or bigint) range and have clear constraints set, using an integer/bigint is the recommended storage option.

Sorting out storage is just the first step to representing money in our Ruby application, though. You might also need to handle different currencies, formatting the values based on the location of users (e.g., USD is formatted as $1,234.56, whereas the same amount in Euros is formatted as €1.234,56).

While it is possible to implement this manually, a gem like money-rails can handle it out of the box for you.

Enter money-rails for Ruby on Rails

Money-Rails integrates the money gem in Rails. It can handle automatic conversion between the database representation of money to Money instances. These instances can then be used to access all helpers available from the money gem (we will get to an overview of those helpers later in this post).

Additionally, money-rails also has helpers for migrations and configurable validation options. Let’s start by creating a product with a price. We'll use the monetize helper to do that:

# db/migrate/20230803062226_create_products.rb
class CreateProducts < ActiveRecord::Migration[7.0]
  def change
    create_table :products do |t|
      t.string :title, null: false
      t.monetize :price, null: false

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

t.monetize :price adds price_cents and price_currency columns to the table. In addition to this, we need to mark the price_cents column that money-rails will control in the model.

# app/models/product.rb
class Product < ApplicationRecord
  monetize :price_cents
end
Enter fullscreen mode Exit fullscreen mode

With just a few lines, we now have access to a lot of helpers.
We can create a new product by simply passing a price attribute. The price will automatically be converted to a cent value for the database. When accessing the price later, we will have instances of Money.

irb> product = Product.new(title: "Foo", price: 19.99)
# => #<Product:0x0000000108ff9e30 id: nil, title: "Foo", price_cents: 1999, price_currency: "USD", created_at: nil, updated_at: nil>

irb> product.price
# => #<Money fractional:1999 currency:USD>
Enter fullscreen mode Exit fullscreen mode

You can also pass the attributes directly:

irb> product = Product.new(title: "Foo", price_cents: 1999, price_currency: "EUR")
# => #<Product:0x0000000108ff2130 id: nil, title: "Foo", price_cents: 1999, price_currency: "EUR", created_at: nil, updated_at: nil>

irb> product.price.format
# => "€19,99"
Enter fullscreen mode Exit fullscreen mode

Currency in the money Gem for Ruby

Currencies are consistently represented as instances of Money::Currency. This not only includes the name of the currency, but a lot of additional information like the name, symbol, iso code, etc.:

irb(main):042:0> product.price.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: >
Enter fullscreen mode Exit fullscreen mode

Check out the money gem's currency documentation.

Let's say you only have a single currency in your application and don’t need to track currency inside your database. First, set a default currency for money in an initializer:

# config/initializers/money.rb
MoneyRails.configure do |config|
  # set the default currency
  config.default_currency = :usd
end
Enter fullscreen mode Exit fullscreen mode

In the migration, disable the currency column:

# db/migrate/20230803062226_create_products.rb
class CreateProducts < ActiveRecord::Migration[7.0]
  def change
    create_table :products do |t|
      t.string :title, null: false
      t.monetize :price, null: false, currency: { present: false }

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

There are many other Money-Rails migration helpers that allow null values for amounts or configure default amounts/currencies.

Validations with monetize

You can configure validations on money using the monetize Active Record helper. To do this, update your model to opt in for validations:

class Product < ApplicationRecord
  monetize :price_cents,
           numericality: {
             greater_than_or_equal_to: 0,
             less_than_or_equal_to: 1000
           }
end
Enter fullscreen mode Exit fullscreen mode

Now, if you try to save a product with an invalid price, validation will fail:

irb> product = Product.new(title: "Foo", price: -10)
irb> product.save!
# => raise_validation_error: Validation failed: Price must be greater than or equal to 0 (ActiveRecord::RecordInvalid)

irb> product.errors
# => #<ActiveModel::Errors [#<ActiveModel::Error attribute=price, type=greater_than_or_equal_to, options={:allow_nil=>nil, :value=>-10, :count=>0}>]>
Enter fullscreen mode Exit fullscreen mode

Localization

Money can be configured to either use the location information provided by i18n or to localize based on the amount’s currency. Use the locale_backend to configure this — usually in an initializer like this:

# config/initializers/money.rb
Money.locale_backend = :currency
Enter fullscreen mode Exit fullscreen mode

In the above example, we set it to :currency. This uses the formatting rules defined for each currency:

irb> product = Product.new(title: "Foo", price_cents: 15_999_00, price_currency: "USD")
irb> product.price.format
# => "$15,999.00"

irb> product.price_currency = "EUR"
irb> product.price.format
# => "€15.999,00"
Enter fullscreen mode Exit fullscreen mode

To configure a consistently formatted amount regardless of the amount’s currency, use the :i18n locale backend:

irb> Money.locale_backend = :i18n
irb> I18n.locale = :en

irb> Product.new(title: "Foo", price_cents: 15_999_00, price_currency: "USD").price.format
# => "$15,999.00"
irb> Product.new(title: "Foo", price_cents: 15_999_00, price_currency: "EUR").price.format
# => "€15,999.00"
Enter fullscreen mode Exit fullscreen mode

When using the locale backend i18n, it is also possible to configure the formatting using the translation files:

# config/locale/en.yml
en:
  number:
    currency:
      format:
        delimiter: ","
        format: "%u%n"
        precision: 2
        separator: "."
        significant: false
        strip_insignificant_zeros: false
        unit: "$"
Enter fullscreen mode Exit fullscreen mode

Note that if you are using rails-i18n, configuration is automatically available for many locales.

Computations

Money instances are usable as regular numerical values with all operators, so you can compare values and perform arithmetic operations (using +, -, *, /). This makes it much more intuitive to perform operations on objects containing money.

# Comparisons - both value and currency must be equal for equality
irb> p1 = Product.new(price_cents: 1000, price_currency: "USD")
irb> p2 = Product.new(price_cents: 1000, price_currency: "USD")
irb> p1.price == p2.price
# => true
Enter fullscreen mode Exit fullscreen mode

Since mathematical operators work as expected, you can easily compute sum/average or price discounts.

# Compute invoice total by summing all line item amounts
irb> item = LineItem.new(amount_cents: 1000, amount_currency: "USD")
irb> discount = LineItem.new(amount_cents: -250, amount_currency: "USD")
irb> total = item.amount + discount.amount
# => #<Money fractional:750 currency:USD>

# Find the average price of a product
irb> average = Product.sum(&:price) / Product.count
# => #<Money fractional:1000 currency:USD>
Enter fullscreen mode Exit fullscreen mode

If you need to access the underlying value from a Money instance, you can do that using amount or cents:

irb> Product.new(price_cents: 1000, price_currency: "USD").amount
# => BigDecimal(10)

irb> Product.new(price_cents: 1000, price_currency: "USD").cents
# => 1000 (Integer)
Enter fullscreen mode Exit fullscreen mode

Currency Exchange

If you are working with multiple currencies, you can automatically convert two different currencies based on exchange rates using exchange rate stores.

The default implementation is just an in-memory store. It stores the exchange rates you provide using Money.add_rate.

To use it, first add a rate. Then, you can use the exchange_to method to convert a money object to another currency.

irb> Money.add_rate("USD", "EUR", 0.92)
irb> Product.new(price_cents: 1000, price_currency: "USD").price.exchange_to("EUR")
# => #<Money fractional:920 currency:EUR>
Enter fullscreen mode Exit fullscreen mode

If a conversion rate is not present, it raises a Money::Bank::UnknownRate exception.

irb> Product.new(price_cents: 1000, price_currency: "USD").price.exchange_to("EUR")
# => No conversion rate known for 'USD' -> 'INR' (Money::Bank::UnknownRate)
Enter fullscreen mode Exit fullscreen mode

So, if you are using this in production, you must provide the exchange rate before you perform any conversions.

You can provide a custom exchange rate store to handle this automatically. There are also some exchange rate implementations already available as gems.
For example, you can drop in the eu_central_bank exchange rate store to fetch the latest exchange rates from the European Central Bank.

Once the gem is installed, you can set it as the default bank in the initializer:

# config/initializers/money.rb
require 'eu_central_bank'
eu_bank = EuCentralBank.new
Money.default_bank = eu_bank

# Update rates from the EU Central Bank
# Ideally this should be performed periodically in a cron job.
# If you need conversions rarely, you can call it before using exchange_to.
eu_bank.update_rates
Enter fullscreen mode Exit fullscreen mode

Once set up, you can simply exchange any currency, and it should work:

irb> Product.new(price_cents: 1000, price_currency: "USD").price.exchange_to("EUR")
# => Money.from_cents(92, "EUR")
Enter fullscreen mode Exit fullscreen mode

Wrap Up

In this post, we saw how to store and work with money in a Rails application.

Money-Rails greatly simplifies the whole process and provides helper methods that make working with money safer and more maintainable in the long run.

Many other options are available to customize this for your Ruby on Rails app. Check out the full details in the money and money-rails guides.

Happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Top comments (0)