MoneyAttribute
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
endThat's it. Product.new(price: 12.dollars).price is a Money.
Why MoneyAttribute?
-
No serialization boilerplate — declare once, read/write
Moneyeverywhere. - 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 aMoneyback. - Currency handling — each record stores its own currency and accepts registered currencies at assignment time.
-
Built on Rails primitives — uses
ActiveRecord::Type,composed_of, andnormalizesunder 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:initializerThe 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
endDefault 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
endThis 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
endConfiguration
# config/initializers/money_attribute.rb
MoneyAttribute.configure do |config|
config.default_currency = 'USD'
endSee 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
subunitfor decimal precision —I18nlocale settings forprecisionare 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 flaggedRecords 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 # => 1999Use
: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_decimalis 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 }
endYou 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 }
endModel 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_currencyRaises 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
endclass 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
endQuerying
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 recordsFor 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 objectorder_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 :ascpluck_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) # => nilsum_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) # => falsemoney_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)
endThe 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 # bigintNaming
| 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 :priceQuerying
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
- 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 testThe dummy Rails app under test/dummy exercises the engine in a full Rails environment.
Contributing
Bug reports welcome at gferraz/money-attribute.