money_attribute 0.14.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd68138109c717791722388f0465c0bbd5296ffe2f30f2cb0521a74585f72483
4
- data.tar.gz: 35b179441b1a9e8ef462fffcb0f9c4079929e3a1da6f775e82c67586dd952f2f
3
+ metadata.gz: cb71d942a1dd57ae30fa3d1d8941efacf402d9346192b99cca4d0eabf69246fb
4
+ data.tar.gz: 70dbb86d5d328e5d42fd09593358445d931261b59212d49f3a8e6a450d275c09
5
5
  SHA512:
6
- metadata.gz: '058e831db4a2bf193eda3117e83271c7b8ce7fa0e1be8a24dcd810e988cf7afd24fb7ed05f752a3f2368888f8051e9c117cd7311338a87a011cec80c783d2c12'
7
- data.tar.gz: 24346e562f17164b1c1a478eca95b3e4eb38e023112a80bc5e626c6efffaf679c86ebda193fdac5f721f23304c66fcb4c4c54e68cdcb64f990bfaf88f7d9b5ca
6
+ metadata.gz: 93e1d50ccda57e7a535fba457e4551525923358b45ef629b381bb04c900eb1fc7b76c1aa11d18410a0669fb96f610a53df1e76e10dcc58860e1d0cd8e3dd19f6
7
+ data.tar.gz: 55d8b84306bf8988e066d426e525af1d6bfb2b0f202727a9a3a38aa5a52f9461d57edac0d5c003f5996141a5e662439d661109ba6aab975fd420b882ea754a29
data/README.md CHANGED
@@ -3,17 +3,38 @@
3
3
  [![CI](https://github.com/gferraz/money-attribute/actions/workflows/ci.yml/badge.svg)](https://github.com/gferraz/money-attribute/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/money_attribute.svg)](https://badge.fury.io/rb/money_attribute)
5
5
 
6
- Store and read Active Record attributes as `Mint::Money` objects with a single `money_attribute` declaration. No manual serialization, no boilerplate.
6
+ Store and read Active Record attributes as `Mint::Money` objects with no manual serialization.
7
+
8
+ `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](#single-column-mode-money_amount-fixed-currency)).
7
9
 
8
10
  ```ruby
9
11
  class Product < ApplicationRecord
10
- money_attribute :price, currency: 'USD' # fixed currency, single column
11
- money_attribute :total # multi-currency, two columns
12
+ money_attribute :price
12
13
  end
13
14
 
14
- Product.new(price: 12).price # => [USD 12.00]
15
+ p = Product.new(price: 12.44.dollars).price # => [USD 12.00]
16
+ p.price * 2 # => [USD 24.88]
15
17
  ```
16
18
 
19
+ ## Table of contents
20
+
21
+ - [Quick start](#quick-start)
22
+ - [Why MoneyAttribute](#why-moneyattribute)
23
+ - [Requirements](#requirements)
24
+ - [Installation](#installation)
25
+ - [Migration helpers](#migration-helpers)
26
+ - [Configuration](#configuration)
27
+ - [Usage](#usage)
28
+ - [Column type detection](#column-type-detection)
29
+ - [Custom column names](#custom-column-names)
30
+ - [Column resolution](#column-resolution)
31
+ - [Querying](#querying)
32
+ - [Convenience methods](#convenience-methods)
33
+ - [Form helpers](#form-helpers)
34
+ - [Roadmap](#roadmap)
35
+ - [Development & Contributing](#development)
36
+ - [License](#license)
37
+
17
38
  ## Quick start
18
39
 
19
40
  ```sh
@@ -27,7 +48,7 @@ class CreateProducts < ActiveRecord::Migration[8.1]
27
48
  def change
28
49
  create_table :products do |t|
29
50
  t.string :name
30
- t.money_attribute :price
51
+ t.money_attribute :price # price: decimal(20,4), price_currency: string
31
52
  t.timestamps
32
53
  end
33
54
  end
@@ -37,16 +58,15 @@ end
37
58
  ```ruby
38
59
  # app/models/product.rb
39
60
  class Product < ApplicationRecord
40
- money_attribute :price, currency: 'USD'
61
+ money_attribute :price
41
62
  end
42
63
  ```
43
64
 
44
- That's it. `Product.new(price: 12).price` is a `Mint::Money`.
65
+ That's it. `Product.new(price: 12.dollars).price` is a `Mint::Money`.
45
66
 
46
67
  ## Why MoneyAttribute?
47
68
 
48
69
  - **No serialization boilerplate** — declare once, read/write `Mint::Money` everywhere.
49
- - **Two storage modes** — single column for fixed-currency apps (simpler), amount+currency columns for multi-currency records (more flexible).
50
70
  - **Integer or decimal columns** — auto-detects the column type and adjusts serialization (e.g. integer stores cents, decimal stores unit value).
51
71
  - **Normalizes everything** — pass a number, string, or `Mint::Money`; always get a `Mint::Money` back.
52
72
  - **Currency enforcement** — fixed-currency attributes reject wrong currencies at assignment time.
@@ -56,16 +76,16 @@ That's it. `Product.new(price: 12).price` is a `Mint::Money`.
56
76
 
57
77
  | Feature | MoneyAttribute | money-rails |
58
78
  |---|---|---|
59
- | **Declaration** | `money_attribute :price` | `monetize :price_cents` |
79
+ | **Declare** | `t.money_attribute :price` / `money_attribute :price` or `t.money_amount :price` / `money_amount :price` | `monetize :price_cents` |
60
80
  | **Column types** | `integer`, `decimal`, `bigint` — auto-detected | `integer` cents only |
61
- | **Storage modes** | Single column, composite (amount+currency)| Single cents column, composite (cents+currency) |
81
+ | **Storage modes** | Composite (amount+currency), single column | Single cents column, composite (cents+currency) |
62
82
  | **Decimal columns** | Native — `t.decimal :price` | Not supported — must convert to cents manually |
63
83
  | **Multi-currency** | `money_attribute :price` (convention: `<name>_amount` + `<name>_currency`) | `monetize :price_cents, with_currency: :price_currency` |
64
84
  | **Rails integration** | `ActiveRecord::Type` + `composed_of` — no monkey-patches | `monetize` overrides reader/writer methods |
65
85
  | **Query (fixed)** | `Model.where(price: money)` — `=`, `IN`, `BETWEEN`, `ORDER`, `SUM` | Through cents column (`price_cents`) |
66
86
  | **Query (multi)** | `Model.where(price: money)` | `Model.where(price_cents:, price_currency:)` |
67
- | **Internal amount** | `Rational` | `BigDecimal` |
68
- | **Performance** | See [BENCHMARKS.md](BENCHMARKS.md) — wins 9/11 cells | |
87
+ | **Internal amount** | `Rational` | `BigDecimal` |
88
+ | **Performance** | See [BENCHMARKS.md](BENCHMARKS.md) — wins 9/11 cells ||
69
89
 
70
90
  For a detailed side-by-side comparison, see [COMPARISON.md](COMPARISON.md).
71
91
 
@@ -73,7 +93,7 @@ For a detailed side-by-side comparison, see [COMPARISON.md](COMPARISON.md).
73
93
 
74
94
  - Ruby 3.3+
75
95
  - Rails 7.1.3.2+
76
- - [Minting](https://github.com/gferraz/minting) 1.8.0+
96
+ - [Minting](https://github.com/gferraz/minting) 2.0+
77
97
 
78
98
  ## Installation
79
99
 
@@ -91,19 +111,32 @@ The generator creates `config/initializers/money_attribute.rb`.
91
111
 
92
112
  ## Migration helpers
93
113
 
94
- 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.
114
+ ### `money_attribute` (composite amount + currency)
115
+
116
+ Primary migration helper for multi-currency attributes. Creates two columns — amount and currency.
117
+
118
+ | Method | Action |
119
+ |---|---|
120
+ | `add_money_attribute` / `t.money_attribute` | Amount column + currency column |
121
+ | `remove_money_attribute` / `t.remove_money_attribute` | Drops both columns |
122
+
123
+ Default columns: `decimal(20,4)` for amount + `string(16)` for currency.
124
+
125
+ ### Column types
95
126
 
96
- 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.
127
+ | Amount type | Column type | Precision/Scale | Maximum | Integer digits |
128
+ |---|---|---|---|---|
129
+ | `:crypto_decimal` | `decimal` | `36/18` | ~1 quintillion | 18 |
130
+ | `:fiat_decimal` | `decimal` | `20/4` | ~10 quadrillion | 16 |
131
+ | `:fiat_integer` | `bigint` | — | ~922 trillion | ~15 |
97
132
 
98
133
  ```ruby
99
134
  class CreateProducts < ActiveRecord::Migration[8.1]
100
135
  def change
101
136
  create_table :products do |t|
102
137
  t.string :name
103
- t.money_attribute :price # price (decimal) + price_currency (string)
104
- t.money_attribute :price_amount # price_amount + price_currency (strips _amount suffix)
105
- t.money_attribute :fee, currency: false # single column, no currency
106
- t.money_attribute :tax, amount: { type: :bigint } # bigint amount + currency
138
+ t.money_attribute :multi # decimal(20,4) + currency
139
+ t.money_attribute :tax, amount: { type: :fiat_integer } # bigint + currency
107
140
  t.timestamps
108
141
  end
109
142
  end
@@ -111,24 +144,23 @@ end
111
144
 
112
145
  class AddPriceToProducts < ActiveRecord::Migration[8.1]
113
146
  def change
114
- add_money_attribute :products, :price # add price + price_currency
115
- add_money_attribute :products, :discount, amount: { type: :integer }
116
- remove_money_attribute :products, :obsolete_fee # reversible in change
147
+ add_money_attribute :products, :price # price + price_currency
148
+ remove_money_attribute :products, :obsolete_fee # reversible in change
117
149
  end
118
150
  end
119
151
  ```
120
152
 
121
153
  ### Naming
122
154
 
155
+ **`money_attribute` (composite):**
156
+
123
157
  | Migration call | Columns created | Model declaration |
124
158
  |---|---|---|
125
- | `t.money_attribute :price` | `price` decimal + `price_currency` string | `money_attribute :price` |
126
- | `t.money_attribute :price_amount` | `price_amount` decimal + `price_currency` string | `money_attribute :price` |
127
- | `t.money_attribute :price, currency: false` | `price` decimal | `money_attribute :price` |
128
- | `t.money_attribute :price, amount: { type: :integer }` | `price` integer + `price_currency` string | `money_attribute :price` |
159
+ | `t.money_attribute :price` | `price` decimal(20,4) + `price_currency` string(16) | `money_attribute :price` |
160
+ | `t.money_attribute :price_amount` | `price_amount` decimal(20,4) + `price_currency` string(16) | `money_attribute :price` |
161
+ | `t.money_attribute :price, amount: { type: :fiat_integer }` | `price` bigint + `price_currency` string(16) | `money_attribute :price` |
129
162
  | `t.money_attribute :price, amount: { column: :a }, currency: { column: :c }` | `a` + `c` | `money_attribute :price, mapping: { amount: :a, currency: :c }` |
130
- | `t.money_attribute :price, currency: { limit: 3 }` | `price` decimal + `price_currency` string(3) | `money_attribute :price` |
131
- | `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` |
163
+ | `t.money_attribute :price, currency: { limit: 5 }` | `price` decimal(20,4) + `price_currency` string(5) | `money_attribute :price` |
132
164
  | `t.remove_money_attribute :price` | Removes `price` + `price_currency` | `money_attribute :price` |
133
165
 
134
166
  Inside `change_table`:
@@ -156,13 +188,13 @@ MoneyAttribute integrates with Rails I18n to automatically format money amounts
156
188
 
157
189
  With `I18n.locale` set to `:en`:
158
190
  ```ruby
159
- Mint.money(1234.56, 'USD').to_s # => "$1,234.56"
191
+ Money.from(1234.56, 'USD').to_s # => "$1,234.56"
160
192
  ```
161
193
 
162
194
  Switch to `:'pt-BR'` and the separators change automatically (requires [`rails-i18n`](https://github.com/svenfuchs/rails-i18n) or your own locale file):
163
195
  ```ruby
164
196
  I18n.locale = :'pt-BR'
165
- Mint.money(1234.56, 'USD').to_s # => "$1.234,56"
197
+ Money.from(1234.56, 'USD').to_s # => "$1.234,56"
166
198
  ```
167
199
 
168
200
  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).
@@ -186,42 +218,16 @@ en:
186
218
  When any of `positive`, `negative`, or `zero` is present, a Hash format is built. Missing keys fall back to `format`:
187
219
 
188
220
  ```ruby
189
- Mint.money(1234.56, 'USD').to_s # => "$1,234.56"
190
- Mint.money(-1234.56, 'USD').to_s # => "($1,234.56)"
191
- Mint.money(0, 'USD').to_s # => "--"
221
+ Money.from(1234.56, 'USD').to_s # => "$1,234.56"
222
+ Money.from(-1234.56, 'USD').to_s # => "($1,234.56)"
223
+ Money.from(0, 'USD').to_s # => "--"
192
224
  ```
193
225
 
194
226
  If none of those keys are set, `format` is used as a plain string (simple formatting).
195
227
 
196
228
  > 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.
197
229
 
198
- ## Usage — Two modes
199
-
200
- ### Decision table
201
-
202
- | | Fixed currency (single column) | Multi-currency (amount + currency) |
203
- |---|---|---|
204
- | **Migration** | `t.money_attribute :price` (or `t.decimal :price`) | `t.money_attribute :price` (or `t.decimal :price_amount` + `t.string :price_currency`) |
205
- | **Model** | `money_attribute :price, currency: 'USD'` | `money_attribute :price` |
206
- | **When to use** | Column always holds the same currency | Each row can hold a different currency |
207
- | **Column type** | `decimal`, `integer`, or `bigint` | `decimal`, `integer`, or `bigint` for amount; `string` for currency |
208
- | **Query** | `Product.where(price: 10.to_money('USD'))` — full type support | `Offer.where(price: 10.to_money('EUR'))` — equality only |
209
-
210
- ### Fixed currency
211
-
212
- ```ruby
213
- class Product < ApplicationRecord
214
- money_attribute :price, currency: 'USD'
215
- end
216
-
217
- product = Product.new(price: 12)
218
- product.price # => [USD 12.00]
219
-
220
- Product.new(price: 12.to_money('EUR'))
221
- # => ArgumentError: ... has different currency. Only USD allowed.
222
- ```
223
-
224
- ### Multi-currency
230
+ ## Usage
225
231
 
226
232
  ```ruby
227
233
  class Offer < ApplicationRecord
@@ -239,15 +245,29 @@ offer.price.currency.code # => "USD"
239
245
 
240
246
  Unlike fixed-currency attributes, composite mode does not enforce a specific currency — any registered currency is accepted at assignment.
241
247
 
248
+ ### Invalid currencies in the database
249
+
250
+ 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:
251
+
252
+ ```ruby
253
+ offer = Offer.find(42)
254
+ offer.price # => [XXX 10.00] # amount preserved, currency flagged
255
+ ```
256
+
257
+ Records with XXX currency are easily queryable for cleanup:
258
+
259
+ ```ruby
260
+ Offer.where(price_currency: 'XXX')
261
+ ```
262
+
242
263
  ## Column type detection
243
264
 
244
- Declare the column as `decimal`, `integer`, or `bigint` the gem adapts:
265
+ Select the amount column type via the `type:` option. The gem adapts serialization accordingly:
245
266
 
246
267
  ```ruby
247
268
  # Migration
248
269
  create_table :orders do |t|
249
- t.bigint :total_amount # stored as cents (subunits)
250
- t.string :total_currency
270
+ t.money_attribute :total, amount: { type: :fiat_integer } # bigint — stored as subunits
251
271
  end
252
272
 
253
273
  # Model
@@ -258,17 +278,7 @@ end
258
278
  Order.new(total: 19.99.to_money('USD')).total_amount # => 1999
259
279
  ```
260
280
 
261
- Same for fixed-currency attributes:
262
-
263
- ```ruby
264
- # Migration
265
- t.bigint :price
266
-
267
- # Model (no change needed)
268
- money_attribute :price, currency: 'USD'
269
- ```
270
-
271
- > Use `integer`/`bigint` for large tables (faster, smaller). Use `decimal` when SQL-level readability matters.
281
+ > 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.
272
282
 
273
283
  ## Custom column names
274
284
 
@@ -294,17 +304,16 @@ end
294
304
 
295
305
  ## Column resolution
296
306
 
297
- When you declare `money_attribute :name`, the gem resolves which database columns to use by checking the table schema in this order:
307
+ `money_attribute :name` is always composite. It resolves columns in this order:
298
308
 
299
- | Step | Condition | Columns used | Mode |
300
- |---|---|---|---|
301
- | 1 | `mapping:` provided | As specified | Explicit composite |
302
- | 2 | `name_currency` column exists | `name` + `name_currency` | Composite (multi-currency) |
303
- | 3 | `name == 'amount'` AND `currency` column exists | `amount` + `currency` | Composite (multi-currency) |
304
- | 4 | `name` column exists (no currency partner) | `name` alone | Single-column (fixed-currency) |
305
- | 5 | None of the above (name column missing) | `name_amount` + `name_currency` (convention) | Composite (multi-currency) |
309
+ | Step | Condition | Columns used |
310
+ |---|---|---|
311
+ | 1 | `mapping:` provided | As specified (missing keys fall back to `<name>_amount` / `<name>_currency`) |
312
+ | 2 | `name_currency` column exists AND `name` column exists | `name` + `name_currency` |
313
+ | 3 | `name == 'amount'` AND `currency` column exists | `amount` + `currency` |
314
+ | 4 | None of the above | `<name>_amount` + `<name>_currency` (convention) |
306
315
 
307
- Step 5 raises `ArgumentError` if the convention columns don't exist in the table.
316
+ Step 4 raises `ArgumentError` if the convention columns don't exist. For single-column fixed-currency attributes, see [`money_amount`](#single-column-mode-money_amount-fixed-currency).
308
317
 
309
318
  **Example**
310
319
 
@@ -316,7 +325,6 @@ create_table :financial_transactions do |t|
316
325
  t.string :discount_currency, limit: 3
317
326
  t.decimal :price_amount
318
327
  t.string :price_currency, limit: 3
319
- t.bigint :surplus
320
328
  t.bigint :tax
321
329
  t.decimal :total_amount
322
330
  t.string :currency_code, limit: 3
@@ -327,47 +335,134 @@ end
327
335
  class FinancialTransaction < ApplicationRecord
328
336
  money_attribute :amount # step 3: amount(int) + currency
329
337
  money_attribute :discount # step 2: discount(int) + discount_currency
330
- money_attribute :price # step 4: price_amount(dec) + price_currency
331
- money_attribute :surplus, currency: 'EUR' # step 5: surplus(int) (single-column, will use EUR)
332
- money_attribute :tax # step 5: tax(int) (single-column, will use default currency)
338
+ money_attribute :price # step 4: price_amount + price_currency
333
339
  money_attribute :total, mapping: { amount: :total_amount, currency: :currency_code } # step 1: explicit
340
+ money_amount :tax # single-column, fixed-currency (uses default currency)
334
341
  end
335
342
  ```
336
343
 
337
344
  ## Querying
338
345
 
339
- Fixed-currency attributes support Rails-native querying through the custom type:
346
+ ### Rails-native queries
347
+
348
+ Multi-currency (`money_attribute`) attributes support equality queries via `composed_of`:
349
+
350
+ ```ruby
351
+ Offer.where(price: 10.to_money('EUR'))
352
+ ```
353
+
354
+ For comparisons, use the backing columns directly:
355
+
356
+ ```ruby
357
+ Offer.where(price_amount: 10..20, price_currency: 'EUR')
358
+ Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')
359
+ ```
360
+
361
+ Fixed-currency (`money_amount`) attributes support full Rails-native querying through the custom type — equality, IN, BETWEEN, ordering, and aggregation all work:
340
362
 
341
363
  ```ruby
342
- # Equality
343
- Product.where(price: 10.to_money('USD'))
364
+ Product.where(price: 10.to_money('USD')) # equality
365
+ Product.where(price: [10.to_money('USD'), 20.to_money('USD')]) # IN
366
+ Product.where(price: 10.to_money('USD')..20.to_money('USD')) # BETWEEN
367
+ Product.order(price: :desc) # ordering
368
+ Product.where(price: 10.to_money('USD')).sum(:price) # aggregation
369
+ ```
370
+
371
+ ### Money-aware query helpers
372
+
373
+ For multi-currency attributes, manually decomposing columns is tedious. The query helpers handle this automatically — just pass Money objects:
344
374
 
345
- # IN clause
346
- Product.where(price: [10.to_money('USD'), 20.to_money('USD')])
375
+ #### `where_amount`
347
376
 
348
- # BETWEEN
349
- Product.where(price: 10.to_money('USD')..20.to_money('USD'))
377
+ Filters by amount value. Accepts a scalar, Range, or Array.
350
378
 
351
- # Ordering
352
- Product.order(price: :desc)
379
+ ```ruby
380
+ Offer.where_amount(price: 10) # equality (any currency)
381
+ Offer.where_amount(price: [10, 30]) # IN — matches EUR 10, USD 30
382
+ Offer.where_amount(price: 10..100) # BETWEEN (inclusive)
383
+ Offer.where_amount(price: 10...100) # BETWEEN (exclusive upper bound)
384
+ ```
385
+
386
+ Ranges work across currencies — `10..50` matches EUR 10 and USD 50:
387
+
388
+ ```ruby
389
+ Offer.create!(price: 10.euros)
390
+ Offer.create!(price: 50.dollars)
353
391
 
354
- # Aggregation
355
- Product.where(price: 10.to_money('USD')).sum(:price)
392
+ Offer.where_amount(price: 10..50) # => both records
356
393
  ```
357
394
 
358
- Multi-currency attributes support equality queries via `composed_of`:
395
+ For integer (subunit) columns, pass `Mint::Money` objects directly — subunit conversion is handled automatically:
359
396
 
360
397
  ```ruby
361
- Offer.where(price: 10.to_money('EUR'))
398
+ FinancialTransaction.where_amount(amount: [10.dollars, 10.yens])
399
+ FinancialTransaction.where_amount(amount: 10.dollars..100.dollars)
362
400
  ```
363
401
 
364
- For comparisons on multi-currency attributes, use the backing columns directly:
402
+ For decimal columns, raw numbers work:
365
403
 
366
404
  ```ruby
367
- Offer.where(price_amount: 10..20, price_currency: 'EUR')
368
- Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')
405
+ SimpleOffer.where_amount(price: 50)
406
+ SimpleOffer.where_amount(price: 10..100)
369
407
  ```
370
408
 
409
+ #### `where_currency`
410
+
411
+ Filters by currency code. Composite attributes only — raises `ArgumentError` for single-column attributes.
412
+
413
+ ```ruby
414
+ Offer.where_currency(price: 'EUR')
415
+ Offer.where_currency(price: 10.euros.currency) # also accepts Currency object
416
+ ```
417
+
418
+ #### `order_by_amount`
419
+
420
+ Orders by amount. Composite attributes sort by currency ASC first, then amount. Single-column attributes sort by amount only.
421
+
422
+ ```ruby
423
+ Offer.order_by_amount(price: :asc) # EUR 10, EUR 100, USD 50
424
+ Offer.order_by_amount(price: :desc) # EUR 100, EUR 10, USD 50
425
+ Offer.order_by_amount(price: nil) # defaults to :asc
426
+ ```
427
+
428
+ #### `pluck_amount`
429
+
430
+ Returns money-aware amounts. Follows Rails' `pluck` arity — one attribute returns flat values, multiple attributes return row arrays.
431
+
432
+ ```ruby
433
+ Offer.pluck_amount(:price) # => [EUR 10.00, USD 20.00]
434
+ Offer.pluck_amount(:amount, :discount) # => [[USD 100.00, EUR 20.00], ...]
435
+ ```
436
+
437
+ #### `pick_amount`
438
+
439
+ Returns a single money-aware value. Follows Rails' `pick` arity.
440
+
441
+ ```ruby
442
+ Offer.pick_amount(:price) # => EUR 10.00
443
+ Offer.pick_amount(:amount, :discount) # => [USD 100.00, EUR 20.00]
444
+ Offer.none.pick_amount(:price) # => nil
445
+ ```
446
+
447
+ #### `sum_amount`
448
+
449
+ Sums amounts grouped by currency for composite attributes. Accepts a single attribute name only.
450
+
451
+ ```ruby
452
+ Offer.sum_amount(:price)
453
+ # => [EUR 30.00, USD 70.00] (one Money per currency, sorted by code)
454
+
455
+ SimpleOffer.sum_amount(:price)
456
+ # => [BRL 60.00] (single-column always returns one Money)
457
+
458
+ Offer.none.sum_amount(:price)
459
+ # => [Mint::Money(0, 'BRL')] (empty result returns zero Money)
460
+ ```
461
+
462
+ ### Notes
463
+
464
+ 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.
465
+
371
466
  ## Convenience methods
372
467
 
373
468
  MoneyAttribute adds small helpers on `Numeric` and `String`:
@@ -378,7 +473,7 @@ MoneyAttribute adds small helpers on `Numeric` and `String`:
378
473
  12.euros # => [EUR 12.00]
379
474
  ```
380
475
 
381
- > If you prefer not to extend core classes, use `Mint.money(12, 'USD')` instead.
476
+ > If you prefer not to extend core classes, use `Money.from(12, 'USD')` instead.
382
477
 
383
478
  ## Form helpers
384
479
 
@@ -394,10 +489,67 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
394
489
  <% end %>
395
490
  ```
396
491
 
492
+ ### Single-column mode — `money_amount` (fixed-currency)
493
+
494
+ `money_amount` wraps a numeric column as `Mint::Money` using the application's default currency. No per-row currency. A lighter alternative when you don't need multi-currency support.
495
+
496
+ The accessor name must match the column name. `money_amount` does not support custom column mapping.
497
+
498
+ #### Migration helpers
499
+
500
+ | Method | Action |
501
+ |---|---|
502
+ | `add_money_amount` / `t.money_amount` | Amount column only |
503
+ | `remove_money_amount` / `t.remove_money_amount` | Drops the column |
504
+
505
+ Default column: `decimal(20,4)`. The top-level `type:` shortcut selects the column type:
506
+
507
+ ```ruby
508
+ t.money_amount :price # decimal(20,4)
509
+ t.money_amount :btc_balance, type: :crypto_decimal # decimal(36,18)
510
+ t.money_amount :qty, type: :fiat_integer # bigint
511
+ ```
512
+
513
+ #### Naming
514
+
515
+ | Migration call | Columns created | Model declaration |
516
+ |---|---|---|
517
+ | `t.money_amount :price` | `price` decimal(20,4) | `money_amount :price` |
518
+ | `t.money_amount :btc, type: :crypto_decimal` | `btc` decimal(36,18) | `money_amount :btc` |
519
+ | `t.money_amount :price, type: :fiat_integer` | `price` bigint | `money_amount :price` |
520
+ | `t.money_amount :price, type: :fiat_decimal` | `price` decimal(20,4) | `money_amount :price` |
521
+
522
+ #### Usage
523
+
524
+ ```ruby
525
+ class Product < ApplicationRecord
526
+ money_amount :price
527
+ end
528
+
529
+ product = Product.new(price: 12)
530
+ product.price # => [USD 12.00]
531
+
532
+ Product.new(price: 12.to_money('EUR'))
533
+ # => ArgumentError: ... has different currency. Only USD allowed.
534
+ ```
535
+
536
+ #### Column type shortcut
537
+
538
+ ```ruby
539
+ # Migration
540
+ t.money_amount :price, type: :fiat_integer # bigint column
541
+
542
+ # Model
543
+ money_amount :price
544
+ ```
545
+
546
+ #### Querying
547
+
548
+ Fixed-currency attributes support full Rails-native querying — see [Querying](#querying) for examples.
549
+
397
550
  ## Roadmap
398
551
 
399
552
  1. **Method-level currency** — lambda-based currency resolution for multi-tenant and instance-level scenarios
400
- 2. Prepare to official 1.0 launh
401
553
 
402
554
  Contributions and suggestions are welcome — open an issue or PR at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
403
555
 
data/Rakefile CHANGED
@@ -15,16 +15,60 @@ Rake::TestTask.new(:test_run) do |t|
15
15
  end
16
16
 
17
17
  desc 'Migrate test database'
18
- task :test_db_migrate do
19
- Dir.chdir('test/dummy') do
20
- sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate')
21
- end
18
+ task :test_db_migrate do # rubocop:disable Rails/RakeEnvironment
19
+ sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
22
20
  end
23
21
 
24
22
  desc 'Run tests (migrates test DB first)'
25
23
  task test: %i[test_db_migrate test_run]
26
24
 
25
+ # --- Adapter-specific tasks ---
26
+
27
+ %w[postgresql mysql2].each do |adapter|
28
+ namespace :test do
29
+ desc "Migrate test database (#{adapter})"
30
+ task "db_migrate:#{adapter}" do # rubocop:disable Rails/RakeEnvironment
31
+ sh({ 'RAILS_ENV' => 'test', 'DATABASE_ADAPTER' => adapter },
32
+ 'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
33
+ end
34
+
35
+ desc "Run tests against #{adapter}"
36
+ task adapter do # rubocop:disable Rails/RakeEnvironment
37
+ Rake::Task[:"test:db_migrate:#{adapter}"].invoke
38
+ sh({ 'DATABASE_ADAPTER' => adapter }, 'bundle', 'exec', 'rake', 'test_run')
39
+ end
40
+ end
41
+ end
42
+
43
+ desc 'Run tests against all adapters (sqlite3, postgresql, mysql2)'
44
+ task 'test:all' do # rubocop:disable Rails/RakeEnvironment
45
+ Rake::Task[:test].invoke
46
+ %w[postgresql mysql2].each do |adapter|
47
+ Rake::Task[:"test:#{adapter}"].invoke
48
+ end
49
+ end
50
+
51
+ # --- Benchmark ---
52
+
27
53
  desc 'Run money_attribute vs money-rails benchmark'
28
- task :bench do
29
- sh({ 'RAILS_ENV' => 'test' }, 'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
54
+ task bench: :test_db_migrate do
55
+ puts
56
+ puts '=' * 80
57
+ puts 'money_attribute (minting gem)'
58
+ puts '=' * 80
59
+ sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'minting' },
60
+ 'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
61
+
62
+ puts
63
+ puts '=' * 80
64
+ puts 'money-rails (money gem)'
65
+ puts '=' * 80
66
+ sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'money_rails',
67
+ 'BUNDLE_GEMFILE' => 'Gemfile.benchmark' },
68
+ 'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
69
+ end
70
+
71
+ desc 'Generate consolidated benchmark report (markdown)'
72
+ task 'bench:report' do # rubocop:disable Rails/RakeEnvironment
73
+ ruby 'benchmark/report.rb'
30
74
  end
@@ -2,21 +2,29 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  MoneyAttribute.configure do |config|
5
+ # To set the default currency
6
+ #
7
+ # It must be a registered currency
8
+ #
9
+ config.default_currency = 'USD'
10
+
11
+ # Register built-in crypto currencies
12
+ #
13
+ # 1. Register specific crypto currencies:
14
+ # Mint::Currency.register_crypto('BTC', 'ETH')
15
+ #
16
+ # 2. Register all built-in crypto currencies at once:
17
+ # Mint::Currency.register_all_crypto
18
+ #
19
+ # See available crypto currencies:
20
+ # Mint::Currency.crypto_currencies
21
+
5
22
  # Register a custom currency
6
23
  #
7
24
  # Example:
8
25
  # config.added_currencies = [
9
- # {currency: 'CRC', subunit: 2, symbol: '₡'},
10
- # {currency: 'NGN', subunit: 3, symbol: '₦'}
26
+ # {currency: 'ZCRC', subunit: 2, symbol: '₡'},
27
+ # {currency: 'ZNGN', subunit: 3, symbol: '₦'}
11
28
  # ]
12
- config.added_currencies = [
13
- { currency: 'XCRC', subunit: 2, symbol: '₡' },
14
- { currency: 'XNGN', subunit: 3, symbol: '₦' }
15
- ]
16
-
17
- # To set the default currency
18
- #
19
- # It must be a registered currency
20
- #
21
- config.default_currency = 'BRL'
29
+ config.added_currencies = []
22
30
  end