Store and read Active Record attributes as Mint::Money objects with a single money_attribute declaration. No manual serialization, no boilerplate.
class Product < ApplicationRecord
money_attribute :price, currency: 'USD' # fixed currency, single column
money_attribute :total # multi-currency, two columns
end
Product.new(price: 12).price # => [USD 12.00]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
t.timestamps
end
end
end# app/models/product.rb
class Product < ApplicationRecord
money_attribute :price, currency: 'USD'
endThat's it. Product.new(price: 12).price is a Mint::Money.
- No serialization boilerplate — declare once, read/write
Mint::Moneyeverywhere. - Two storage modes — single column for fixed-currency apps (simpler), amount+currency columns for multi-currency records (more flexible).
- 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
Mint::Money; always get aMint::Moneyback. - Currency enforcement — fixed-currency attributes reject wrong currencies at assignment time.
- Built on Rails primitives — uses
ActiveRecord::Type,composed_of, andnormalizesunder the hood. No monkey-patching of core classes.
| Feature | MoneyAttribute | money-rails |
|---|---|---|
| Declaration | money_attribute :price |
monetize :price_cents |
| Column types | integer, decimal, bigint — auto-detected |
integer cents only |
| Storage modes | Single column, 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.
- Ruby 3.3+
- Rails 7.1.3.2+
- Minting 1.8.0+
# Gemfile
gem 'money_attribute'bundle install
bin/rails g money_attribute:initializerThe generator creates config/initializers/money_attribute.rb.
MoneyAttribute adds add_money_attribute / remove_money_attribute for existing tables and t.money_attribute / t.remove_money_attribute for create_table / change_table blocks.
By default t.money_attribute :price creates a decimal(16,4) amount column and a string currency column — both nullable, no default. Pass amount: { type: :integer } to store subunits instead, or currency: false to skip the currency column entirely.
class CreateProducts < ActiveRecord::Migration[8.1]
def change
create_table :products do |t|
t.string :name
t.money_attribute :price # price (decimal) + price_currency (string)
t.money_attribute :price_amount # price_amount + price_currency (strips _amount suffix)
t.money_attribute :fee, currency: false # single column, no currency
t.money_attribute :tax, amount: { type: :bigint } # bigint amount + currency
t.timestamps
end
end
end
class AddPriceToProducts < ActiveRecord::Migration[8.1]
def change
add_money_attribute :products, :price # add price + price_currency
add_money_attribute :products, :discount, amount: { type: :integer }
remove_money_attribute :products, :obsolete_fee # reversible in change
end
end| Migration call | Columns created | Model declaration |
|---|---|---|
t.money_attribute :price |
price decimal + price_currency string |
money_attribute :price |
t.money_attribute :price_amount |
price_amount decimal + price_currency string |
money_attribute :price |
t.money_attribute :price, currency: false |
price decimal |
money_attribute :price |
t.money_attribute :price, amount: { type: :integer } |
price integer + price_currency string |
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: 3 } |
price decimal + price_currency string(3) |
money_attribute :price |
t.money_attribute :price, amount: { precision: 14, scale: 2, null: false }, currency: { limit: 3, default: 'USD' } |
price decimal(14,2) NOT NULL + price_currency string(3) DEFAULT 'USD' |
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# 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).
MoneyAttribute integrates with Rails I18n to automatically format money amounts according to the current locale.
With I18n.locale set to :en:
Mint.money(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'
Mint.money(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 Mint::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:
Mint.money(1234.56, 'USD').to_s # => "$1,234.56"
Mint.money(-1234.56, 'USD').to_s # => "($1,234.56)"
Mint.money(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.
| Fixed currency (single column) | Multi-currency (amount + currency) | |
|---|---|---|
| Migration | t.money_attribute :price (or t.decimal :price) |
t.money_attribute :price (or t.decimal :price_amount + t.string :price_currency) |
| Model | money_attribute :price, currency: 'USD' |
money_attribute :price |
| When to use | Column always holds the same currency | Each row can hold a different currency |
| Column type | decimal, integer, or bigint |
decimal, integer, or bigint for amount; string for currency |
| Query | Product.where(price: 10.to_money('USD')) — full type support |
Offer.where(price: 10.to_money('EUR')) — equality only |
class Product < ApplicationRecord
money_attribute :price, currency: 'USD'
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.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.
Declare the column as decimal, integer, or bigint — the gem adapts:
# Migration
create_table :orders do |t|
t.bigint :total_amount # stored as cents (subunits)
t.string :total_currency
end
# Model
class Order < ApplicationRecord
money_attribute :total
end
Order.new(total: 19.99.to_money('USD')).total_amount # => 1999Same for fixed-currency attributes:
# Migration
t.bigint :price
# Model (no change needed)
money_attribute :price, currency: 'USD'Use
integer/bigintfor large tables (faster, smaller). Usedecimalwhen SQL-level readability matters.
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
}
endThe 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`
endWhen you declare money_attribute :name, the gem resolves which database columns to use by checking the table schema in this order:
| Step | Condition | Columns used | Mode |
|---|---|---|---|
| 1 | mapping: provided |
As specified | Explicit composite |
| 2 | name_currency column exists |
name + name_currency |
Composite (multi-currency) |
| 3 | name == 'amount' AND currency column exists |
amount + currency |
Composite (multi-currency) |
| 4 | name column exists (no currency partner) |
name alone |
Single-column (fixed-currency) |
| 5 | None of the above (name column missing) | name_amount + name_currency (convention) |
Composite (multi-currency) |
Step 5 raises ArgumentError if the convention columns don't exist in the table.
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 :surplus
t.bigint :tax
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(dec) + price_currency
money_attribute :surplus, currency: 'EUR' # step 5: surplus(int) (single-column, will use EUR)
money_attribute :tax # step 5: tax(int) (single-column, will use default currency)
money_attribute :total, mapping: { amount: :total_amount, currency: :currency_code } # step 1: explicit
endFixed-currency attributes support Rails-native querying through the custom type:
# Equality
Product.where(price: 10.to_money('USD'))
# IN clause
Product.where(price: [10.to_money('USD'), 20.to_money('USD')])
# BETWEEN
Product.where(price: 10.to_money('USD')..20.to_money('USD'))
# Ordering
Product.order(price: :desc)
# Aggregation
Product.where(price: 10.to_money('USD')).sum(:price)Multi-currency attributes support equality queries via composed_of:
Offer.where(price: 10.to_money('EUR'))For comparisons on multi-currency attributes, use the backing columns directly:
Offer.where(price_amount: 10..20, price_currency: 'EUR')
Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')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
Mint.money(12, 'USD')instead.
- Method-level currency — lambda-based currency resolution for multi-tenant and instance-level scenarios
- Prepare to official 1.0 launh
Contributions and suggestions are welcome — open an issue or PR at gferraz/money-attribute.
bundle install
bundle exec rake testThe dummy Rails app under test/dummy exercises the engine in a full Rails environment.
Bug reports and pull requests welcome at gferraz/money-attribute.