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

>= 2.1
>= 7.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. A simpler money_amount variant is also available for fixed-currency models (see note).

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 enforcement — fixed-currency attributes reject wrong 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 or t.money_amount :price / money_amount :price monetize :price_cents
Column types integer, decimal, bigint — auto-detected integer cents only
Storage modes Composite (amount+currency), single column 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

Naming

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, mapping: { amount: :a, currency: :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 don't follow the <name>_amount / <name>_currency convention:

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

The mapping keys are :amount and :currency; values are your database column names. You can provide only one key — the other falls back to the <name>_amount / <name>_currency convention:

class Invoice < ApplicationRecord
  money_attribute :total, mapping: { amount: :total_amount }
  # currency column inferred as `total_currency`
end

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

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

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.bigint  :tax
  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
  money_amount  :tax                        # single-column, fixed-currency (uses default currency)
end

Querying

Rails-native queries

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

Offer.where(price: 10.to_money('EUR'))

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')

Fixed-currency (money_amount) attributes support full Rails-native querying through the custom type — equality, IN, BETWEEN, ordering, and aggregation all work:

Product.where(price: 10.to_money('USD'))                        # equality
Product.where(price: [10.to_money('USD'), 20.to_money('USD')]) # IN
Product.where(price: 10.to_money('USD')..20.to_money('USD'))   # BETWEEN
Product.order(price: :desc)                                     # ordering
Product.where(price: 10.to_money('USD')).sum(:price)            # aggregation

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)               # equality (any currency)
Offer.where_amount(price: [10, 30])         # IN — matches EUR 10, USD 30
Offer.where_amount(price: 10..100)          # BETWEEN (inclusive)
Offer.where_amount(price: 10...100)         # BETWEEN (exclusive upper bound)

Ranges work across currencies — 10..50 matches EUR 10 and USD 50:

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

Offer.where_amount(price: 10..50)           # => both records

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

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

For decimal columns, raw numbers work:

SimpleOffer.where_amount(price: 50)
SimpleOffer.where_amount(price: 10..100)

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.

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 and money_amount_field to Rails form builders. money_field renders a text input with the locale-formatted money string; money_amount_field renders a number input with the raw decimal value.

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

  <%= form.label :tax %>
  <%= form.money_amount_field :tax %>  <!-- number input, e.g. "1234.56" -->
<% end %>

Single-column mode — money_amount (fixed-currency)

money_amount wraps a numeric column as Money using the application's default currency. No per-row currency. A lighter alternative when you don't need multi-currency support.

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

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 — see Querying for examples.

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.