Keep Your Ruby Code Maintainable with Money-Rails
Tags: ruby, rails
Published: 2023-12-13 15:43:52

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](https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-NUMERIC-DECIMAL) 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](https://github.com/RubyMoney/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](https://github.com/RubyMoney/money-rails#migration-helpers) helper to do that:

```ruby
# 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
```

`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.

```ruby
# app/models/product.rb
class Product < ApplicationRecord
  monetize :price_cents
end
```

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`.

```ruby
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>
```

You can also pass the attributes directly:

```ruby
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"
```

## 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.:

```ruby
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: >
```

Check out the [`money` gem's currency documentation](https://github.com/RubyMoney/money/tree/main#currency).

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:

```ruby
# config/initializers/money.rb
MoneyRails.configure do |config|
  # set the default currency
  config.default_currency = :usd
end
```

In the migration, disable the currency column:

```ruby
# 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
```

There are many [other Money-Rails migration helpers](https://github.com/RubyMoney/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:

```ruby
class Product < ApplicationRecord
  monetize :price_cents,
           numericality: {
             greater_than_or_equal_to: 0,
             less_than_or_equal_to: 1000
           }
end
```

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

```ruby
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}>]>
```

## 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:

```ruby
# config/initializers/money.rb
Money.locale_backend = :currency
```

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

```ruby
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"
```

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

```ruby
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"
```

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

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

Note that if you are using [`rails-i18n`](https://github.com/svenfuchs/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.

```ruby
# 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
```

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

```ruby
# 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>
```

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

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

irb> Product.new(price_cents: 1000, price_currency: "USD").cents
# => 1000 (Integer)
```

## 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.

```ruby
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>
```

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

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

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](https://github.com/RubyMoney/money/tree/main#exchange-rate-stores) 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`](https://github.com/RubyMoney/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:

```ruby
# 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
```

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

```ruby
irb> Product.new(price_cents: 1000, price_currency: "USD").price.exchange_to("EUR")
# => Money.from_cents(92, "EUR")
```

## 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`](https://github.com/RubyMoney/money) and [`money-rails`](https://github.com/RubyMoney/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](https://blog.appsignal.com/ruby-magic)!**