MoneyAttribute
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]
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 :price
t.
end
end
end
# app/models/product.rb
class Product < ApplicationRecord
money_attribute :price, currency: 'USD'
end
That's it. Product.new(price: 12).price is a Mint::Money.
Why MoneyAttribute?
- 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.
At a glance — vs money-rails
| 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.
Requirements
- Ruby 3.3+
- Rails 7.1.3.2+
- Minting 1.8.0+
Installation
# Gemfile
gem 'money_attribute'
bundle install
bin/rails g money_attribute:initializer
The generator creates config/initializers/money_attribute.rb.
Migration helpers
MoneyAttribute adds add_money / remove_money for existing tables and t.money / t.remove_money for create_table / change_table blocks:
class CreateProducts < ActiveRecord::Migration[8.1]
def change
create_table :products do |t|
t.string :name
t.money :price # price (decimal) + price_currency (string)
t.money :price_amount # price_amount + price_currency (strips _amount suffix)
t.money :fee, currency: false # single column, no currency
t.money :tax, type: :bigint # bigint amount + currency
t.
end
end
end
class AddPriceToProducts < ActiveRecord::Migration[8.1]
def change
add_money :products, :price # add price + price_currency
add_money :products, :discount, type: :integer
remove_money :products, :obsolete_fee # reversible in change
end
end
Naming
| Migration call | Columns created | Model declaration |
|---|---|---|
t.money :price |
price decimal + price_currency string |
money_attribute :price |
t.money :price_amount |
price_amount decimal + price_currency string |
money_attribute :price |
t.money :price, currency: false |
price decimal |
money_attribute :price |
t.money :price, type: :integer |
price integer + price_currency string |
money_attribute :price |
t.money :price, amount: :a, currency: :c |
a + c |
money_attribute :price, mapping: { amount: :a, currency: :c } |
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:
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.
Usage — Two modes
Decision table
| Fixed currency (single column) | Multi-currency (amount + currency) | |
|---|---|---|
| Migration | t.money :price (or t.decimal :price) |
t.money :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 |
Fixed currency
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.
Multi-currency
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"
Column type detection
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 # => 1999
Same 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.
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.
Column resolution
When 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_amount + name_currency columns exist |
name_amount + name_currency |
Composite (multi-currency) |
| 5 | name column exists (no currency partner) |
name alone |
Single-column (fixed-currency) |
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
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(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
end
Querying
Fixed-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')
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
Mint.money(12, 'USD')instead.
Roadmap
- 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.
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 and pull requests welcome at gferraz/money-attribute.