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
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
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>
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"
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: >
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
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
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
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}>]>
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
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"
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"
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: "$"
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
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>
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)
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>
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)
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
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")
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)