0.0
The project is in a healthy, maintained state
Easily add money attributes to your Rails models
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.1
>= 2.1
 Project Readme

MoneyAttribute

CI Gem Version

Store and read Active Record attributes as Money objects with no manual serialization.

money_attribute uses two DB columns (amount + currency) for per-row multi-currency data.

class Product < ApplicationRecord
  money_attribute :price
end

p = Product.new(price: 12.44.dollars).price  # => [USD 12.00]
p.price * 2 # => [USD 24.88]

Table of contents

  • Quick start
  • Why MoneyAttribute
  • Requirements
  • Installation
  • Migration helpers
  • Configuration
  • Usage
  • Column type detection
  • Custom column names
  • Column resolution
  • Querying
  • Convenience methods
  • Form helpers
  • Roadmap
  • Development

Quick start

bundle add money_attribute
bin/rails g money_attribute:initializer
# db/migrate/20260620000000_create_products.rb
class CreateProducts < ActiveRecord::Migration[8.1]
  def change
    create_table :products do |t|
      t.string :name
      t.money_attribute :price           # price: decimal(20,4), price_currency: string
      t.timestamps
    end
  end
end
# app/models/product.rb
class Product < ApplicationRecord
  money_attribute :price
end

That's it. Product.new(price: 12.dollars).price is a Money.

Why MoneyAttribute?

  • No serialization boilerplate — declare once, read/write Money everywhere.
  • Integer or decimal columns — auto-detects the column type and adjusts serialization (e.g. integer stores cents, decimal stores unit value).
  • Normalizes everything — pass a number, string, or Money; always get a Money back.
  • Currency handling — each record stores its own currency and accepts registered currencies at assignment time.
  • Built on Rails primitives — uses ActiveRecord::Type, composed_of, and normalizes under the hood. No monkey-patching of core classes.

At a glance — vs money-rails

Feature MoneyAttribute money-rails
Declare t.money_attribute :price / money_attribute :price monetize :price_cents
Column types integer, decimal, bigint — auto-detected integer cents only
Storage mode Composite (amount+currency) Single cents column, composite (cents+currency)
Decimal columns Native — t.decimal :price Not supported — must convert to cents manually
Multi-currency money_attribute :price (convention: <name>_amount + <name>_currency) monetize :price_cents, with_currency: :price_currency
Rails integration ActiveRecord::Type + composed_of — no monkey-patches monetize overrides reader/writer methods
Query (fixed) Model.where(price: money) — =, IN, BETWEEN, ORDER, SUM Through cents column (price_cents)
Query (multi) Model.where(price: money) Model.where(price_cents:, price_currency:)
Internal amount Rational BigDecimal
Performance See BENCHMARKS.md — wins 9/11 cells —

For a detailed side-by-side comparison, see COMPARISON.md.

Requirements

  • Ruby 3.3+
  • Rails 7.1.3.2+
  • Minting 2.1+

Installation

# Gemfile
gem 'money_attribute'
bundle install
bin/rails g money_attribute:initializer

The generator creates config/initializers/money_attribute.rb.

Migration helpers

money_attribute (composite — amount + currency)

Primary migration helper for multi-currency attributes. Creates two columns — amount and currency.

Method Action
add_money_attribute / t.money_attribute Amount column + currency column
remove_money_attribute / t.remove_money_attribute Drops both columns

Default columns: decimal(20,4) for amount + string(16) for currency.

Column types

Amount type Column type Precision/Scale Maximum Integer digits
:crypto_decimal decimal 36/18 ~1 quintillion 18
:fiat_decimal decimal 20/4 ~10 quadrillion 16
:fiat_integer bigint — ~922 trillion ~15
class CreateProducts < ActiveRecord::Migration[8.1]
  def change
    create_table :products do |t|
      t.string :name
      t.money_attribute :multi                             # decimal(20,4) + currency
      t.money_attribute :tax, amount: { type: :fiat_integer }  # bigint + currency
      t.timestamps
    end
  end
end

class AddPriceToProducts < ActiveRecord::Migration[8.1]
  def change
    add_money_attribute :products, :price           # price + price_currency
    remove_money_attribute :products, :obsolete_fee # reversible in change
  end
end

Default column names

For money_attribute :price, the default columns are price for the amount and price_currency for the currency. The migration helper and model macro use the same mapping:

create_table :products do |t|
  t.money_attribute :price
end

class Product < ApplicationRecord
  money_attribute :price
end

This creates and maps to:

price
price_currency

The amount column defaults to decimal(20,4) and the currency column defaults to string(16).

Naming options

money_attribute (composite):

Migration call Columns created Model declaration
t.money_attribute :price price decimal(20,4) + price_currency string(16) money_attribute :price
t.money_attribute :price_amount price_amount decimal(20,4) + price_currency string(16) money_attribute :price
t.money_attribute :price, amount: { type: :fiat_integer } price bigint + price_currency string(16) money_attribute :price
t.money_attribute :price, amount: { column: :a }, currency: { column: :c } a + c money_attribute :price, amount: { column: :a }, currency: { column: :c }
t.money_attribute :price, currency: { limit: 5 } price decimal(20,4) + price_currency string(5) money_attribute :price
t.remove_money_attribute :price Removes price + price_currency money_attribute :price

Inside change_table:

change_table :products do |t|
  t.remove_money_attribute :obsolete_fee   # removes obsolete_fee + obsolete_fee_currency
end

Configuration

# config/initializers/money_attribute.rb
MoneyAttribute.configure do |config|
  config.default_currency = 'USD'
end

See the Minting gem for full configuration options (custom currencies, formatting, rounding).

I18n / Locale-aware formatting

MoneyAttribute integrates with Rails I18n to automatically format money amounts according to the current locale.

With I18n.locale set to :en:

Money.from(1234.56, 'USD').to_s  # => "$1,234.56"

Switch to :'pt-BR' and the separators change automatically (requires rails-i18n or your own locale file):

I18n.locale = :'pt-BR'
Money.from(1234.56, 'USD').to_s  # => "$1.234,56"

The locale backend reads number.currency.format from your I18n translations and maps Rails format syntax (%n for amount, %u for unit) to Money#to_s. If the translation key is missing (no locale file for that language), it falls back to hardcoded defaults (. decimal, , thousand, %<symbol>s%<amount>f format).

You can configure per-sign formatting by adding positive, negative, and zero keys to your locale:

# config/locales/money_attribute.en.yml
en:
  number:
    currency:
      format:
        format: "%u%n"           # fallback when no per-sign key matches
        positive: "%u%n"         # "$1,234.56"
        negative: "(%u%n)"       # "($1,234.56)"
        zero: "--"               # "--"
        separator: "."
        delimiter: ","

When any of positive, negative, or zero is present, a Hash format is built. Missing keys fall back to format:

Money.from(1234.56, 'USD').to_s  # => "$1,234.56"
Money.from(-1234.56, 'USD').to_s # => "($1,234.56)"
Money.from(0, 'USD').to_s        # => "--"

If none of those keys are set, format is used as a plain string (simple formatting).

Formatting respects the currency's own subunit for decimal precision — I18n locale settings for precision are ignored since that is a currency property, not a locale one.

Usage

class Offer < ApplicationRecord
  money_attribute :price
end

offer = Offer.new(price: 15.to_money('EUR'))
offer.price          # => [EUR 15.00]
offer.price_amount   # => 15.0
offer.price_currency # => "EUR"

offer = Offer.new(price: '12')
offer.price.currency.code # => "USD"

Unlike fixed-currency attributes, composite mode does not enforce a specific currency — any registered currency is accepted at assignment.

Invalid currencies in the database

If the currency column contains a value that is not a registered currency (e.g. a legacy code that was removed, or data corruption), money_attribute does not crash. The currency resolves to XXX (ISO 4217 "No Currency") and the monetary amount is preserved:

offer = Offer.find(42)
offer.price # => [XXX 10.00]  # amount preserved, currency flagged

Records with XXX currency are easily queryable for cleanup:

Offer.where(price_currency: 'XXX')

Column type detection

Select the amount column type via the type: option. The gem adapts serialization accordingly:

# Migration
create_table :orders do |t|
  t.money_attribute :total, amount: { type: :fiat_integer }   # bigint — stored as subunits
end

# Model
class Order < ApplicationRecord
  money_attribute :total
end

Order.new(total: 19.99.to_money('USD')).total_amount # => 1999

Use :fiat_integer (bigint) for large tables — smaller and sufficient for most fiat use cases (~922 trillion max). Use :fiat_decimal (decimal) when SQL-level readability matters. For cryto currencies support, :crypto_decimal is mandatory.

Custom column names

If your columns do not follow the default mapping, use the same nested column options as the migration helper:

class Invoice < ApplicationRecord
  money_attribute :total,
    amount: { column: :total_amount },
    currency: { column: :currency_code }
end

You can provide only one nested option; the other falls back to the default mapping. The older mapping: syntax remains supported (but it will eventually be deprecated):

class Invoice < ApplicationRecord
  money_attribute :total, amount: { column: :total_amount }
  money_attribute :subtotal, mapping: { amount: :subtotal }
end

Model declarations accept only the nested :column option. Migration-only options such as :type, :null, and :default belong in the migration.

Column resolution

money_attribute :name is always composite. Columns are resolved in two phases:

Phase 1 — Default mapping is determined by which columns exist:

Condition Default columns
name_currency exists AND name exists name + name_currency
name == 'amount' AND currency exists amount + currency
Otherwise <name>_amount + <name>_currency

The first row is the canonical mapping used by the migration helpers. The final row supports existing schemas that use the <name>_amount / <name>_currency convention. For example, money_attribute :price also maps to price_amount + price_currency when those are the available columns.

Phase 2 — Override via mapping: is merged on top of the default. Missing keys inherit from the default mapping:

money_attribute :total, mapping: { amount: :total_amount }
# currency falls back to default => :total_currency

Raises ArgumentError if the resolved columns don't exist. For single-column fixed-currency attributes, see the advanced money_amount section.

Example

create_table :financial_transactions do |t|
  t.integer :amount
  t.string  :currency, limit: 3
  t.integer :discount
  t.string  :discount_currency, limit: 3
  t.decimal :price_amount
  t.string  :price_currency, limit: 3
  t.decimal :total_amount
  t.string  :currency_code, limit: 3
end
class FinancialTransaction < ApplicationRecord
  money_attribute :amount                   # step 3: amount(int) + currency
  money_attribute :discount                 # step 2: discount(int) + discount_currency
  money_attribute :price                    # step 4: price_amount + price_currency
  money_attribute :total, mapping: { amount: :total_amount, currency: :currency_code }  # step 1: explicit
end

Querying

Rails-native queries

Multi-currency (money_attribute) attributes support equality queries via composed_of:

Offer.where(price: 10.euros)

For comparisons, use the backing columns directly:

Offer.where(price_amount: 10..20, price_currency: 'EUR')
Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')

Composite query matrix

For money_attribute, use native Rails queries for simple equality. Use the money-aware helpers when working with Money objects or when you need an explicit currency filter alongside an amount query:

Operation Native Rails Money-aware helper
Equality where(price: 10.euros) where_amount(price: 10.euros)
IN Query backing columns directly where_amount(price: [10.euros, 20.euros])
Range/comparison Query backing columns directly where_currency(price: 'EUR').where_amount(price: 10.euros..20.euros)
Currency filter where(price_currency: 'EUR') where_currency(price: 'EUR')
Ordering Query backing columns directly order_by_amount(price: :desc)
Pluck Returns backing column values pluck_amount(:price) returns Money
Pick Returns backing column values pick_amount(:price) returns Money
Sum Group and sum backing columns directly sum_amount(:price) groups by currency

where_amount accepts Money values and converts them to the backing amount unit. Pair it with where_currency when the currency must be constrained.

Money-aware query helpers

For multi-currency attributes, manually decomposing columns is tedious. The query helpers handle this automatically — just pass Money objects:

where_amount

Filters by amount value. Accepts a scalar, Range, or Array.

Offer.where_amount(price: 10.euros)                    # equality
Offer.where_amount(price: [10.euros, 30.euros])        # IN
Offer.where_amount(price: 10.euros..100.euros)         # BETWEEN (inclusive)
Offer.where_amount(price: 10.euros...100.euros)        # BETWEEN (exclusive upper bound)

For a currency-specific range, constrain the currency explicitly:

Offer.create!(price: 10.euros)
Offer.create!(price: 50.euros)

Offer.where_currency(price: 'EUR').where_amount(price: 10.euros..50.euros) # => both records

For integer (subunit) columns, pass Money objects directly — subunit conversion is handled automatically:

FinancialTransaction.where_amount(amount: [10.dollars, 20.dollars])
FinancialTransaction.where_amount(amount: 10.dollars..100.dollars)

where_currency

Filters by currency code. Composite attributes only — raises ArgumentError for single-column attributes.

Offer.where_currency(price: 'EUR')
Offer.where_currency(price: 10.euros.currency)  # also accepts Currency object

order_by_amount

Orders by amount. Composite attributes sort by currency ASC first, then amount. Single-column attributes sort by amount only.

Offer.order_by_amount(price: :asc)   # EUR 10, EUR 100, USD 50
Offer.order_by_amount(price: :desc)  # EUR 100, EUR 10, USD 50
Offer.order_by_amount(price: nil)    # defaults to :asc

pluck_amount

Returns money-aware amounts. Follows Rails' pluck arity — one attribute returns flat values, multiple attributes return row arrays.

Offer.pluck_amount(:price)                  # => [EUR 10.00, USD 20.00]
Offer.pluck_amount(:amount, :discount)      # => [[USD 100.00, EUR 20.00], ...]

pick_amount

Returns a single money-aware value. Follows Rails' pick arity.

Offer.pick_amount(:price)                   # => EUR 10.00
Offer.pick_amount(:amount, :discount)       # => [USD 100.00, EUR 20.00]
Offer.none.pick_amount(:price)              # => nil

sum_amount

Sums amounts grouped by currency for composite attributes. Accepts a single attribute name only.

Offer.sum_amount(:price)
# => [EUR 30.00, USD 70.00]  (one Money per currency, sorted by code)

SimpleOffer.sum_amount(:price)
# => [BRL 60.00]  (single-column always returns one Money)

Offer.none.sum_amount(:price)
# => [BRL 0.00]  (empty result returns zero Money)

Notes

All query helpers raise ArgumentError for non-money attributes. Internally, money attribute metadata is registered per model class. The same attribute name can be used safely in different models, but subclasses do not automatically inherit a parent model's registered money attributes.

Introspection

Use money_attribute? and money_attribute_kind when building generic model or form code:

Product.money_attribute?(:price)       # => true
Product.money_attribute_kind(:price)   # => :composite or :single
Product.money_attribute?(:unknown)     # => false

money_attribute_kind returns :composite for money_attribute, :single for money_amount, and nil for undeclared attributes.

Convenience methods

MoneyAttribute adds small helpers on Numeric and String:

12.to_money('USD')    # => [USD 12.00]
12.dollars            # => [USD 12.00]
12.euros              # => [EUR 12.00]

If you prefer not to extend core classes, use Money.from(12, 'USD') instead.

Form helpers

MoneyAttribute adds money_field to Rails form builders for composite money attributes. It renders a text input with the locale-formatted money string.

<%= form_with model: @product do |form| %>
  <%= form.label :price %>
  <%= form.money_field :price %>       <!-- text input, e.g. "$1,234.56" -->
<% end %>

The field submits a locale-formatted string under the normal model parameter:

# app/controllers/products_controller.rb
def create
  @product = Product.new(product_params)

  if @product.save
    redirect_to @product
  else
    render :new, status: :unprocessable_entity
  end
end

private

def product_params
  params.require(:product).permit(:name, :price)
end

The model normalizes the submitted string through MoneyAttribute::Converter. For a composite attribute, provide a separate currency input or set the currency explicitly when the form must accept a currency different from the configured default.

Advanced: money_amount (single-column, fixed-currency)

money_amount is an advanced compatibility utility for schemas where one numeric column stores amounts in one fixed currency. It is not suitable for per-row or multi-currency data. New applications should normally use money_attribute.

The accessor name must match the column name. money_amount does not support custom column mapping.

Use money_amount_field when rendering a form for this mode; it renders a number input with the raw decimal value.

Migration helpers

Method Action
add_money_amount / t.money_amount Amount column only
remove_money_amount / t.remove_money_amount Drops the column

Default column: decimal(20,4). The top-level type: shortcut selects the column type:

t.money_amount :price                               # decimal(20,4)
t.money_amount :btc_balance, type: :crypto_decimal  # decimal(36,18)
t.money_amount :qty,         type: :fiat_integer    # bigint

Naming

Migration call Columns created Model declaration
t.money_amount :price price decimal(20,4) money_amount :price
t.money_amount :btc, type: :crypto_decimal btc decimal(36,18) money_amount :btc
t.money_amount :price, type: :fiat_integer price bigint money_amount :price
t.money_amount :price, type: :fiat_decimal price decimal(20,4) money_amount :price

Usage

class Product < ApplicationRecord
  money_amount :price
end

product = Product.new(price: 12)
product.price # => [USD 12.00]

Product.new(price: 12.to_money('EUR'))
# => ArgumentError: ... has different currency. Only USD allowed.

Column type shortcut

# Migration
t.money_amount :price, type: :fiat_integer  # bigint column

# Model
money_amount :price

Querying

Fixed-currency attributes support full Rails-native querying. The helper methods are also available when a consistent query interface is useful:

Operation Native Rails Money-aware helper
Equality where(price: 10.reais) where_amount(price: 10.reais)
IN where(price: [10.reais, 20.reais]) where_amount(price: [10.reais, 20.reais])
Range/comparison where(price: 10.reais..20.reais) where_amount(price: 10.reais..20.reais)
Currency filter Not applicable Raises ArgumentError
Ordering order(price: :desc) order_by_amount(price: :desc)
Pluck pluck(:price) pluck_amount(:price)
Pick pick(:price) pick_amount(:price)
Sum sum(:price) sum_amount(:price)

Roadmap

  1. Method-level currency — lambda-based currency resolution for multi-tenant and instance-level scenarios

Contributions and suggestions are welcome — open an issue or PR at gferraz/money-attribute.

Development

bundle install
bundle exec rake test

The dummy Rails app under test/dummy exercises the engine in a full Rails environment.

Contributing

Bug reports welcome at gferraz/money-attribute.