money_attribute 0.14.5 → 1.1.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: bcfa5f42f59d3a7f372ddbff422a4e2aa45faccf4f5bd912fc2509e974bda609
4
+ data.tar.gz: 173509ac929dc32881a8bae54be2c3a7c0f92632ab8fae0192d84ae9b42433eb
5
5
  SHA512:
6
- metadata.gz: '058e831db4a2bf193eda3117e83271c7b8ce7fa0e1be8a24dcd810e988cf7afd24fb7ed05f752a3f2368888f8051e9c117cd7311338a87a011cec80c783d2c12'
7
- data.tar.gz: 24346e562f17164b1c1a478eca95b3e4eb38e023112a80bc5e626c6efffaf679c86ebda193fdac5f721f23304c66fcb4c4c54e68cdcb64f990bfaf88f7d9b5ca
6
+ metadata.gz: 18598feb924b703ae70e0fe68a76dd93d3ec9f22753d8556616d8c42ebae8460512e9451ed2e80b96feb44d380e8c03e1d2c0a23f22cf956773d001f5e27d83b
7
+ data.tar.gz: 1178b4b944cbd54b93d8d96f03a55403141086573d5f15f6225a820e65fb9a3e4fae7f0277518563274fa91a7dc7fd6a4ae889e1391dfb69efe72c07de0bb53a
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 |
95
122
 
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.
123
+ Default columns: `decimal(20,4)` for amount + `string(16)` for currency.
124
+
125
+ ### Column types
126
+
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`:
@@ -195,33 +227,7 @@ If none of those keys are set, `format` is used as a plain string (simple format
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).
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,29 @@ 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:
340
-
341
- ```ruby
342
- # Equality
343
- Product.where(price: 10.to_money('USD'))
344
-
345
- # IN clause
346
- Product.where(price: [10.to_money('USD'), 20.to_money('USD')])
347
-
348
- # BETWEEN
349
- Product.where(price: 10.to_money('USD')..20.to_money('USD'))
350
-
351
- # Ordering
352
- Product.order(price: :desc)
353
-
354
- # Aggregation
355
- Product.where(price: 10.to_money('USD')).sum(:price)
356
- ```
357
-
358
- Multi-currency attributes support equality queries via `composed_of`:
346
+ Multi-currency (`money_attribute`) attributes support equality queries via `composed_of`:
359
347
 
360
348
  ```ruby
361
349
  Offer.where(price: 10.to_money('EUR'))
362
350
  ```
363
351
 
364
- For comparisons on multi-currency attributes, use the backing columns directly:
352
+ For comparisons, use the backing columns directly:
365
353
 
366
354
  ```ruby
367
355
  Offer.where(price_amount: 10..20, price_currency: 'EUR')
368
356
  Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')
369
357
  ```
370
358
 
359
+ For fixed-currency (`money_amount`) attributes, see the [single-column section](#single-column-mode--money_amount).
360
+
371
361
  ## Convenience methods
372
362
 
373
363
  MoneyAttribute adds small helpers on `Numeric` and `String`:
@@ -394,10 +384,73 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
394
384
  <% end %>
395
385
  ```
396
386
 
387
+ ### Single-column mode — `money_amount` (fixed-currency)
388
+
389
+ `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.
390
+
391
+ #### Migration helpers
392
+
393
+ | Method | Action |
394
+ |---|---|
395
+ | `add_money_amount` / `t.money_amount` | Amount column only |
396
+ | `remove_money_amount` / `t.remove_money_amount` | Drops the column |
397
+
398
+ Default column: `decimal(20,4)`. The top-level `type:` shortcut selects the column type:
399
+
400
+ ```ruby
401
+ t.money_amount :price # decimal(20,4)
402
+ t.money_amount :btc_balance, type: :crypto_decimal # decimal(36,18)
403
+ t.money_amount :qty, type: :fiat_integer # bigint
404
+ ```
405
+
406
+ #### Naming
407
+
408
+ | Migration call | Columns created | Model declaration |
409
+ |---|---|---|
410
+ | `t.money_amount :price` | `price` decimal(20,4) | `money_amount :price` |
411
+ | `t.money_amount :btc, type: :crypto_decimal` | `btc` decimal(36,18) | `money_amount :btc` |
412
+ | `t.money_amount :price, type: :fiat_integer` | `price` bigint | `money_amount :price` |
413
+ | `t.money_amount :price, type: :fiat_decimal` | `price` decimal(20,4) | `money_amount :price` |
414
+
415
+ #### Usage
416
+
417
+ ```ruby
418
+ class Product < ApplicationRecord
419
+ money_amount :price
420
+ end
421
+
422
+ product = Product.new(price: 12)
423
+ product.price # => [USD 12.00]
424
+
425
+ Product.new(price: 12.to_money('EUR'))
426
+ # => ArgumentError: ... has different currency. Only USD allowed.
427
+ ```
428
+
429
+ #### Column type shortcut
430
+
431
+ ```ruby
432
+ # Migration
433
+ t.money_amount :price, type: :fiat_integer # bigint column
434
+
435
+ # Model
436
+ money_amount :price
437
+ ```
438
+
439
+ #### Querying
440
+
441
+ Fixed-currency attributes support Rails-native querying through the custom type:
442
+
443
+ ```ruby
444
+ Product.where(price: 10.to_money('USD')) # equality
445
+ Product.where(price: [10.to_money('USD'), 20.to_money('USD')]) # IN
446
+ Product.where(price: 10.to_money('USD')..20.to_money('USD')) # BETWEEN
447
+ Product.order(price: :desc) # ordering
448
+ Product.where(price: 10.to_money('USD')).sum(:price) # aggregation
449
+ ```
450
+
397
451
  ## Roadmap
398
452
 
399
453
  1. **Method-level currency** — lambda-based currency resolution for multi-tenant and instance-level scenarios
400
- 2. Prepare to official 1.0 launh
401
454
 
402
455
  Contributions and suggestions are welcome — open an issue or PR at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
403
456
 
data/Rakefile CHANGED
@@ -16,15 +16,31 @@ end
16
16
 
17
17
  desc 'Migrate test database'
18
18
  task :test_db_migrate do
19
- Dir.chdir('test/dummy') do
20
- sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate')
21
- end
19
+ sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate', chdir: 'test/dummy')
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
 
27
25
  desc 'Run money_attribute vs money-rails benchmark'
28
- task :bench do
29
- sh({ 'RAILS_ENV' => 'test' }, 'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
26
+ task bench: :test_db_migrate do
27
+ puts
28
+ puts '=' * 80
29
+ puts 'money_attribute (minting gem)'
30
+ puts '=' * 80
31
+ sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'minting' },
32
+ 'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
33
+
34
+ puts
35
+ puts '=' * 80
36
+ puts 'money-rails (money gem)'
37
+ puts '=' * 80
38
+ sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'money_rails',
39
+ 'BUNDLE_GEMFILE' => 'Gemfile.benchmark' },
40
+ 'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
41
+ end
42
+
43
+ desc 'Generate consolidated benchmark report (markdown)'
44
+ task 'bench:report' do
45
+ ruby 'benchmark/report.rb'
30
46
  end
@@ -10,19 +10,18 @@ module MoneyAttribute
10
10
  end
11
11
  end
12
12
 
13
- # Class-level caches — written during Rails boot (single-threaded),
14
- # read-only during request handling. Safe without synchronization.
15
- def self.config
16
- @config ||= Configuration.new
17
- end
13
+ cfg = Configuration.new
14
+ cached_default = nil
15
+
16
+ define_singleton_method(:config) { cfg }
18
17
 
19
- def self.configure
20
- yield config if block_given?
21
- @default_currency = nil
22
- config
18
+ define_singleton_method(:configure) do |&block|
19
+ block&.call(cfg)
20
+ cached_default = nil
21
+ cfg
23
22
  end
24
23
 
25
- def self.default_currency
26
- @default_currency ||= ::Mint::Currency.resolve!(config.default_currency)
24
+ define_singleton_method(:default_currency) do
25
+ cached_default ||= ::Mint::Currency.resolve!(cfg.default_currency)
27
26
  end
28
27
  end
@@ -1,19 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
+ # :nodoc:
4
5
  class Converter
5
6
  def initialize(currency = MoneyAttribute.default_currency)
6
7
  @default_currency = currency
7
8
  end
8
9
 
9
- def parse(amount, currency = @default_currency)
10
+ def parse(amount)
10
11
  case amount
11
- when Mint::Money, NilClass then amount
12
- when Numeric then Mint::Money.from(amount, currency)
13
- when String then Mint.parse(amount, currency)
12
+ when Money, NilClass then amount
13
+ when Numeric then Money.from(amount, @default_currency)
14
+ when String then Money.parse(amount, @default_currency)
14
15
  else raise ArgumentError, "Cannot convert #{amount.inspect} (#{amount.class}) to Money"
15
16
  end
16
17
  end
18
+
17
19
  alias call parse
18
20
  end
19
21
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc:
4
+ class String
5
+ remove_method :to_money if method_defined?(:to_money)
6
+
7
+ def to_money(currency = MoneyAttribute.default_currency) = Money.parse(self, currency)
8
+ end
@@ -1,15 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # :nodoc
3
+ # :nodoc:
4
4
  class Numeric
5
- remove_method :to_money
5
+ remove_method :to_money if method_defined?(:to_money)
6
6
 
7
7
  def to_money(currency = MoneyAttribute.default_currency) = Mint.money(self, currency)
8
8
  end
9
-
10
- # :nodoc
11
- class String
12
- remove_method :to_money
13
-
14
- def to_money(currency = MoneyAttribute.default_currency) = Mint.parse(self, currency)
15
- end
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
+ # :nodoc:
4
5
  module FormBuilderExtension
5
6
  def money_field(method, options = {})
6
7
  money = object.public_send(method)
7
- value = money&.to_fs(:currency)
8
+ value = money&.to_fs
8
9
 
9
10
  @template.text_field_tag(field_name(method), value,
10
11
  { id: field_id(method) }.merge(options))
@@ -1,33 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
+ # :nodoc:
4
5
  module Macro
5
6
  extend ActiveSupport::Concern
6
7
 
7
- class_methods do
8
- def money_attribute(name, currency: MoneyAttribute.default_currency, mapping: nil)
8
+ # :nodoc:
9
+ module CompositeClassMethods
10
+ def resolve_composite_mapping(name)
9
11
  columns = attribute_names
10
- currency = ::Mint::Currency.resolve!(currency)
11
- name = name.to_s
12
- resolved_mapping = mapping || resolve_mapping(name, columns)
13
-
14
- if columns.include?(name) && resolved_mapping.nil?
15
- define_single_column_money_attribute(name, currency)
16
- else
17
- define_composite_money_attribute(name, resolved_mapping, currency)
18
- end
19
- end
20
-
21
- private
22
-
23
- # --- Preparation (no side effects) ---
24
-
25
- def resolve_mapping(name, columns)
26
- return nil unless columns.include?(name)
27
-
28
12
  if columns.include?("#{name}_currency")
29
- { amount: name, currency: :"#{name}_currency" }
30
- elsif columns.include?('currency') && name == 'amount'
13
+ return { amount: name, currency: :"#{name}_currency" } if columns.include?(name)
14
+
15
+ nil
16
+ elsif name == 'amount' && columns.include?('currency')
31
17
  { amount: name, currency: :currency }
32
18
  end
33
19
  end
@@ -35,8 +21,8 @@ module MoneyAttribute
35
21
  def resolve_composite_for(name, mapping:)
36
22
  composite = { amount: "#{name}_amount", currency: "#{name}_currency" }
37
23
 
38
- composite[:amount] = mapping[:amount].to_s if mapping&.key?(:amount)
39
- composite[:currency] = mapping[:currency].to_s if mapping&.key?(:currency)
24
+ composite[:amount] = mapping[:amount].to_s if mapping&.key?(:amount)
25
+ composite[:currency] = mapping[:currency].to_s if mapping&.key?(:currency)
40
26
 
41
27
  assert_columns_exist!(name, composite)
42
28
  composite
@@ -57,17 +43,18 @@ module MoneyAttribute
57
43
  def money_constructor_for(amount_column)
58
44
  default = MoneyAttribute.default_currency
59
45
  if integer_column?(amount_column)
60
- lambda { |amount, currency|
61
- return nil if amount.nil?
62
-
63
- Mint::Money.from_subunits(amount, currency.presence || default)
64
- }
46
+ build_money_constructor(:from_subunits, default)
65
47
  else
66
- lambda { |amount, currency|
67
- return nil if amount.nil?
48
+ build_money_constructor(:from, default)
49
+ end
50
+ end
68
51
 
69
- Mint::Money.from(amount, currency.presence || default)
70
- }
52
+ def build_money_constructor(method, default)
53
+ lambda do |amount, currency|
54
+ next nil if amount.nil?
55
+
56
+ resolved = Mint::Currency.resolve(currency.presence || default) || 'XXX'
57
+ Mint::Money.public_send(method, amount, resolved)
71
58
  end
72
59
  end
73
60
 
@@ -76,14 +63,6 @@ module MoneyAttribute
76
63
  %i[integer bigint].include?(col&.type)
77
64
  end
78
65
 
79
- # --- Configuration (registers types, normalizers, composed_of) ---
80
-
81
- def define_single_column_money_attribute(name, currency)
82
- column_type = integer_column?(name) ? ActiveRecord::Type::Integer.new : ActiveRecord::Type::Decimal.new
83
- attribute(name.to_sym, :mint_money, currency:, column_type: column_type)
84
- normalizes(name.to_sym, with: Converter.new(currency))
85
- end
86
-
87
66
  def define_composite_money_attribute(name, mapping, currency)
88
67
  aggregated = resolve_composite_for(name, mapping:)
89
68
 
@@ -99,5 +78,26 @@ module MoneyAttribute
99
78
  })
100
79
  end
101
80
  end
81
+
82
+ class_methods do
83
+ def money_attribute(name, currency: MoneyAttribute.default_currency, mapping: nil)
84
+ name = name.to_s
85
+ currency = ::Mint::Currency.resolve!(currency)
86
+ resolved_mapping = mapping || resolve_composite_mapping(name)
87
+
88
+ if resolved_mapping.nil? && attribute_names.include?(name)
89
+ raise ArgumentError,
90
+ "Column '#{name}' exists but no '#{name}_currency' column was found. " \
91
+ 'For single-column fixed-currency attributes, use `money_amount` ' \
92
+ 'instead of `money_attribute`.'
93
+ end
94
+
95
+ define_composite_money_attribute(name, resolved_mapping || {}, currency)
96
+ end
97
+ end
98
+
99
+ included do
100
+ extend CompositeClassMethods
101
+ end
102
102
  end
103
103
  end
@@ -2,61 +2,69 @@
2
2
 
3
3
  module MoneyAttribute
4
4
  module MigrationExtensions
5
+ # :nodoc:
5
6
  module Helper
6
- private
7
+ AMOUNT_CONFIG = {
8
+ crypto_decimal: { type: :decimal, precision: 36, scale: 18 },
9
+ fiat_decimal: { type: :decimal, precision: 20, scale: 4 },
10
+ fiat_integer: { type: :bigint }
11
+ }.freeze
7
12
 
8
- def parse_money_args(accessor, options = {})
9
- name = accessor.to_s
13
+ CURRENCY_MIN_LIMIT = 8
14
+ CURRENCY_DEFAULT_LIMIT = 20
15
+
16
+ private
10
17
 
11
- if options.key?(:amount) && options[:amount].is_a?(Hash)
12
- amount_col = options[:amount][:column]&.to_s || name
13
- opts = options[:amount]
14
- amount_opts = {
15
- type: opts[:type],
16
- null: opts[:null],
17
- default: opts[:default],
18
- precision: opts[:precision],
19
- scale: opts[:scale]
20
- }.compact
21
- else
22
- amount_col = name
23
- amount_opts = {}
18
+ def parse_money_amount_args(accessor, options)
19
+ options ||= {}
20
+ if options.key?(:precision) || options.key?(:scale)
21
+ raise ArgumentError,
22
+ 'precision:/scale: are not configurable — money_attribute uses fixed, ' \
23
+ 'vetted values per type (:crypto_decimal, :fiat_decimal, :fiat_integer) ' \
24
+ 'to prevent under-precision bugs, particularly for crypto amounts.'
24
25
  end
25
26
 
26
- amount_opts[:type] ||= :decimal
27
+ column = (options[:column] || accessor).to_s
27
28
 
28
- if amount_opts[:type] == :decimal && !amount_opts.key?(:precision) && !amount_opts.key?(:scale)
29
- amount_opts[:precision] = 16
30
- amount_opts[:scale] = 4
31
- elsif amount_opts[:type] != :decimal
32
- amount_opts.delete(:precision)
33
- amount_opts.delete(:scale)
29
+ config = AMOUNT_CONFIG[options[:type] || :fiat_decimal]
30
+ unless config
31
+ raise ArgumentError,
32
+ "Invalid money amount type #{options[:type]}. Use :crypto_decimal, :fiat_decimal or :fiat_integer"
34
33
  end
35
34
 
36
- stripped = name.end_with?('_amount') ? name.sub(/_amount$/, '') : name
37
- default_currency_col = if name == 'amount' && !(options[:currency].is_a?(Hash) && options[:currency][:column])
38
- 'currency'
39
- else
40
- "#{stripped}_currency"
41
- end
42
-
43
- if options.key?(:currency) && options[:currency].is_a?(Hash)
44
- currency_col = options[:currency][:column]&.to_s || default_currency_col
45
- opts = options[:currency]
46
- currency_opts = {
47
- limit: opts[:limit],
48
- null: opts[:null],
49
- default: opts[:default]
50
- }.compact
51
- elsif options[:currency] == false
52
- currency_col = nil
53
- currency_opts = {}
54
- else
55
- currency_col = default_currency_col
56
- currency_opts = {}
35
+ options = { null: options[:null], default: options[:default] }.compact
36
+ [column, config.merge(options)]
37
+ end
38
+
39
+ def parse_currency_args(accessor, options)
40
+ options ||= {}
41
+ limit = (options[:limit] || CURRENCY_DEFAULT_LIMIT).to_i
42
+ if limit < CURRENCY_MIN_LIMIT
43
+ raise ArgumentError,
44
+ "currency limit: #{limit} is too small to hold an ISO 4217 code and crypto popular codes" \
45
+ "(minimum #{CURRENCY_MIN_LIMIT}). Omit limit: to use the default of #{CURRENCY_DEFAULT_LIMIT}, " \
46
+ "or pass a value >= #{CURRENCY_MIN_LIMIT}."
57
47
  end
58
48
 
59
- [amount_col, currency_col, amount_opts, currency_opts]
49
+ column = currency_column_name(accessor, options[:column])
50
+ [column, { limit:, null: options[:null], default: options[:default] }.compact]
51
+ end
52
+
53
+ def currency_column_name(accessor, column_override)
54
+ return column_override.to_s if column_override
55
+
56
+ name = accessor.to_s
57
+ return 'currency' if name == 'amount'
58
+
59
+ radical = name.end_with?('_amount') ? name.sub(/_amount$/, '') : name
60
+ "#{radical}_currency"
61
+ end
62
+
63
+ def parse_money_args(accessor, options = {})
64
+ amount_column, amount_options = parse_money_amount_args(accessor, options[:amount])
65
+ currency_column, currency_options = parse_currency_args(accessor, options[:currency])
66
+
67
+ [amount_column, currency_column, amount_options, currency_options]
60
68
  end
61
69
  end
62
70
  end
@@ -4,21 +4,34 @@ require_relative 'helper'
4
4
 
5
5
  module MoneyAttribute
6
6
  module MigrationExtensions
7
+ # :nodoc:
7
8
  module SchemaStatements
8
9
  include Helper
9
10
 
10
11
  def add_money_attribute(table_name, accessor, options = {})
11
12
  amount_col, currency_col, amount_opts, currency_opts = parse_money_args(accessor, options)
12
13
 
13
- add_column(table_name, amount_col, amount_opts[:type], **amount_opts.except(:type))
14
- add_column(table_name, currency_col, :string, **currency_opts) if currency_col
14
+ type = amount_opts.delete(:type)
15
+ add_column(table_name, amount_col, type, **amount_opts)
16
+ add_column(table_name, currency_col, :string, **currency_opts)
15
17
  end
16
18
 
17
19
  def remove_money_attribute(table_name, accessor, options = {})
18
20
  amount_col, currency_col, = parse_money_args(accessor, options)
19
21
 
20
22
  remove_column(table_name, amount_col)
21
- remove_column(table_name, currency_col) if currency_col
23
+ remove_column(table_name, currency_col)
24
+ end
25
+
26
+ def add_money_amount(table_name, accessor, options = {})
27
+ amount_col, amount_opts = parse_money_amount_args(accessor, options)
28
+
29
+ type = amount_opts.delete(:type)
30
+ add_column(table_name, amount_col, type, **amount_opts)
31
+ end
32
+
33
+ def remove_money_amount(table_name, accessor, options = {})
34
+ remove_column(table_name, (options[:column] || accessor).to_s)
22
35
  end
23
36
  end
24
37
  end
@@ -4,6 +4,7 @@ require_relative 'helper'
4
4
 
5
5
  module MoneyAttribute
6
6
  module MigrationExtensions
7
+ # :nodoc:
7
8
  module TableDefinition
8
9
  include Helper
9
10
 
@@ -11,14 +12,26 @@ module MoneyAttribute
11
12
  amount_col, currency_col, amount_opts, currency_opts = parse_money_args(accessor, options)
12
13
 
13
14
  column(amount_col, amount_opts[:type], **amount_opts.except(:type))
14
- column(currency_col, :string, **currency_opts) if currency_col
15
+ column(currency_col, :string, **currency_opts)
15
16
  end
16
17
 
17
18
  def remove_money_attribute(accessor, options = {})
18
19
  amount_col, currency_col, = parse_money_args(accessor, options)
19
20
 
20
21
  remove_column(amount_col)
21
- remove_column(currency_col) if currency_col
22
+ remove_column(currency_col)
23
+ end
24
+
25
+ def money_amount(accessor, options = {})
26
+ amount_col, amount_opts = parse_money_amount_args(accessor, options)
27
+
28
+ column(amount_col, amount_opts[:type], **amount_opts.except(:type))
29
+ end
30
+
31
+ def remove_money_amount(accessor, options = {})
32
+ amount_col, = parse_money_amount_args(accessor, options)
33
+
34
+ remove_column(amount_col)
22
35
  end
23
36
  end
24
37
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # :nodoc:
5
+ module MoneyAmount
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def money_amount(name)
10
+ name = name.to_s
11
+
12
+ assert_column_exists!(name)
13
+
14
+ currency = ::Mint::Currency.resolve!(MoneyAttribute.default_currency)
15
+ column_type = detect_column_type(name)
16
+
17
+ attribute(name.to_sym, MoneyAttribute::Type.new(currency:, column_type:))
18
+ normalizes(name.to_sym, with: Converter.new)
19
+ end
20
+
21
+ private
22
+
23
+ def assert_column_exists!(name)
24
+ return if attribute_names.include?(name)
25
+
26
+ raise ArgumentError,
27
+ "Column '#{name}' does not exist on this table. " \
28
+ "Add a column named '#{name}' or use a different accessor name."
29
+ end
30
+
31
+ def detect_column_type(name)
32
+ col = columns.find { |c| c.name == name }
33
+ %i[integer bigint].include?(col&.type) ? ActiveRecord::Type::Integer.new : ActiveRecord::Type::Decimal.new
34
+ end
35
+ end
36
+ end
37
+ end
@@ -21,24 +21,34 @@ module MoneyAttribute
21
21
  end
22
22
 
23
23
  def self.setup_locale_backend!
24
- ::Mint.locale_backend = lambda {
25
- fmt = I18n.t('number.currency.format', default: {})
26
- translator = ->(s) { s&.gsub('%n', '%<amount>f')&.gsub('%u', '%<symbol>s') }
27
-
28
- format = if fmt.key?(:positive) || fmt.key?(:negative) || fmt.key?(:zero)
29
- {
30
- positive: translator.call(fmt[:positive] || fmt[:format]),
31
- negative: translator.call(fmt[:negative] || fmt[:format]),
32
- zero: translator.call(fmt[:zero] || fmt[:format])
33
- }
34
- else
35
- translator.call(fmt[:format])
36
- end
37
-
38
- { decimal: fmt[:separator], thousand: fmt[:delimiter], format: format }
24
+ ::Mint.locale_backend = method(:build_locale_format).to_proc
25
+ end
26
+
27
+ def self.build_locale_format
28
+ fmt = I18n.t('number.currency.format', default: {})
29
+ { decimal: fmt[:separator], thousand: fmt[:delimiter], format: build_format(fmt) }
30
+ end
31
+
32
+ def self.build_format(fmt)
33
+ if %i[positive negative zero].any? { |k| fmt.key?(k) }
34
+ build_hash_format(fmt)
35
+ else
36
+ translate_format(fmt[:format])
37
+ end
38
+ end
39
+
40
+ def self.build_hash_format(fmt)
41
+ {
42
+ positive: translate_format(fmt[:positive] || fmt[:format]),
43
+ negative: translate_format(fmt[:negative] || fmt[:format]),
44
+ zero: translate_format(fmt[:zero] || fmt[:format])
39
45
  }
40
46
  end
41
47
 
48
+ def self.translate_format(str)
49
+ str.to_s.gsub('%n', '%<amount>f').gsub('%u', '%<symbol>s')
50
+ end
51
+
42
52
  def self.register_custom_currencies!
43
53
  Array(MoneyAttribute.config.added_currencies).each do |currency_data|
44
54
  if currency_data.respond_to?(:values_at)
@@ -10,9 +10,10 @@ module MoneyAttribute
10
10
  end
11
11
 
12
12
  def cast(value)
13
- case value
14
- when String then Mint::Money.parse(value, @currency)
15
- else super
13
+ if value.is_a?(String)
14
+ Mint::Money.parse(value, @currency)
15
+ else
16
+ super
16
17
  end
17
18
  end
18
19
 
@@ -48,13 +49,10 @@ module MoneyAttribute
48
49
  value.to_d
49
50
  end
50
51
  end
51
-
52
- def self.type = :mint_money
53
52
  end
54
53
  end
55
54
 
56
55
  ActiveSupport.on_load(:active_record) do
57
56
  include MoneyAttribute::Macro
58
-
59
- ActiveRecord::Type.register(:mint_money, MoneyAttribute::Type)
57
+ include MoneyAttribute::MoneyAmount
60
58
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
- VERSION = '0.14.5'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -2,8 +2,10 @@
2
2
 
3
3
  require 'minting'
4
4
  require 'money_attribute/core_ext'
5
+ require 'money_attribute/core_ext/string'
5
6
  require 'money_attribute/configuration'
6
7
  require 'money_attribute/macro'
8
+ require 'money_attribute/money_amount'
7
9
  require 'money_attribute/converter'
8
10
  require 'money_attribute/type'
9
11
  require 'money_attribute/form_builder_extension'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: money_attribute
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.5
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gilson Ferraz
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 1.9.0
18
+ version: '2.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 1.9.0
25
+ version: '2.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rails
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 7.1.3.2
32
+ version: '7.1'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 7.1.3.2
39
+ version: '7.1'
40
40
  description: Easily add money attributes to your Rails models
41
41
  email:
42
42
  - gilson@cesar.etc.br
@@ -53,11 +53,13 @@ files:
53
53
  - lib/money_attribute/configuration.rb
54
54
  - lib/money_attribute/converter.rb
55
55
  - lib/money_attribute/core_ext.rb
56
+ - lib/money_attribute/core_ext/string.rb
56
57
  - lib/money_attribute/form_builder_extension.rb
57
58
  - lib/money_attribute/macro.rb
58
59
  - lib/money_attribute/migration_extensions/helper.rb
59
60
  - lib/money_attribute/migration_extensions/schema_statements.rb
60
61
  - lib/money_attribute/migration_extensions/table_definition.rb
62
+ - lib/money_attribute/money_amount.rb
61
63
  - lib/money_attribute/railtie.rb
62
64
  - lib/money_attribute/type.rb
63
65
  - lib/money_attribute/version.rb