money_attribute 1.1.0 → 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 +4 -4
- data/README.md +117 -18
- data/Rakefile +31 -3
- data/lib/generators/templates/money_attribute.rb +20 -12
- data/lib/money_attribute/attribute_spec.rb +78 -0
- data/lib/money_attribute/attribute_spec_registry.rb +50 -0
- data/lib/money_attribute/configuration.rb +43 -15
- data/lib/money_attribute/converter.rb +14 -4
- data/lib/money_attribute/{core_ext.rb → core_ext/numeric.rb} +1 -1
- data/lib/money_attribute/current.rb +13 -0
- data/lib/money_attribute/form_builder_extension.rb +2 -0
- data/lib/money_attribute/macro.rb +45 -68
- data/lib/money_attribute/migration_extensions/helper.rb +1 -2
- data/lib/money_attribute/money_amount.rb +20 -24
- data/lib/money_attribute/query/amount_condition.rb +48 -0
- data/lib/money_attribute/query/amount_order.rb +22 -0
- data/lib/money_attribute/query/currency_condition.rb +23 -0
- data/lib/money_attribute/query/helpers.rb +30 -0
- data/lib/money_attribute/query/pick.rb +38 -0
- data/lib/money_attribute/query/pluck.rb +43 -0
- data/lib/money_attribute/query/sum.rb +40 -0
- data/lib/money_attribute/query.rb +120 -0
- data/lib/money_attribute/railtie.rb +7 -1
- data/lib/money_attribute/type.rb +41 -33
- data/lib/money_attribute/version.rb +1 -1
- data/lib/money_attribute.rb +15 -1
- metadata +14 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb71d942a1dd57ae30fa3d1d8941efacf402d9346192b99cca4d0eabf69246fb
|
|
4
|
+
data.tar.gz: 70dbb86d5d328e5d42fd09593358445d931261b59212d49f3a8e6a450d275c09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 93e1d50ccda57e7a535fba457e4551525923358b45ef629b381bb04c900eb1fc7b76c1aa11d18410a0669fb96f610a53df1e76e10dcc58860e1d0cd8e3dd19f6
|
|
7
|
+
data.tar.gz: 55d8b84306bf8988e066d426e525af1d6bfb2b0f202727a9a3a38aa5a52f9461d57edac0d5c003f5996141a5e662439d661109ba6aab975fd420b882ea754a29
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Store and read Active Record attributes as `Mint::Money` objects with no manual serialization.
|
|
7
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
|
|
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)).
|
|
9
9
|
|
|
10
10
|
```ruby
|
|
11
11
|
class Product < ApplicationRecord
|
|
@@ -188,13 +188,13 @@ MoneyAttribute integrates with Rails I18n to automatically format money amounts
|
|
|
188
188
|
|
|
189
189
|
With `I18n.locale` set to `:en`:
|
|
190
190
|
```ruby
|
|
191
|
-
|
|
191
|
+
Money.from(1234.56, 'USD').to_s # => "$1,234.56"
|
|
192
192
|
```
|
|
193
193
|
|
|
194
194
|
Switch to `:'pt-BR'` and the separators change automatically (requires [`rails-i18n`](https://github.com/svenfuchs/rails-i18n) or your own locale file):
|
|
195
195
|
```ruby
|
|
196
196
|
I18n.locale = :'pt-BR'
|
|
197
|
-
|
|
197
|
+
Money.from(1234.56, 'USD').to_s # => "$1.234,56"
|
|
198
198
|
```
|
|
199
199
|
|
|
200
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).
|
|
@@ -218,9 +218,9 @@ en:
|
|
|
218
218
|
When any of `positive`, `negative`, or `zero` is present, a Hash format is built. Missing keys fall back to `format`:
|
|
219
219
|
|
|
220
220
|
```ruby
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 # => "--"
|
|
224
224
|
```
|
|
225
225
|
|
|
226
226
|
If none of those keys are set, `format` is used as a plain string (simple formatting).
|
|
@@ -313,7 +313,7 @@ end
|
|
|
313
313
|
| 3 | `name == 'amount'` AND `currency` column exists | `amount` + `currency` |
|
|
314
314
|
| 4 | None of the above | `<name>_amount` + `<name>_currency` (convention) |
|
|
315
315
|
|
|
316
|
-
Step 4 raises `ArgumentError` if the convention columns don't exist. For single-column fixed-currency attributes, see [`money_amount`](#single-column-mode
|
|
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).
|
|
317
317
|
|
|
318
318
|
**Example**
|
|
319
319
|
|
|
@@ -343,6 +343,8 @@ end
|
|
|
343
343
|
|
|
344
344
|
## Querying
|
|
345
345
|
|
|
346
|
+
### Rails-native queries
|
|
347
|
+
|
|
346
348
|
Multi-currency (`money_attribute`) attributes support equality queries via `composed_of`:
|
|
347
349
|
|
|
348
350
|
```ruby
|
|
@@ -356,7 +358,110 @@ Offer.where(price_amount: 10..20, price_currency: 'EUR')
|
|
|
356
358
|
Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')
|
|
357
359
|
```
|
|
358
360
|
|
|
359
|
-
|
|
361
|
+
Fixed-currency (`money_amount`) attributes support full Rails-native querying through the custom type — equality, IN, BETWEEN, ordering, and aggregation all work:
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
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:
|
|
374
|
+
|
|
375
|
+
#### `where_amount`
|
|
376
|
+
|
|
377
|
+
Filters by amount value. Accepts a scalar, Range, or Array.
|
|
378
|
+
|
|
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)
|
|
391
|
+
|
|
392
|
+
Offer.where_amount(price: 10..50) # => both records
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
For integer (subunit) columns, pass `Mint::Money` objects directly — subunit conversion is handled automatically:
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
FinancialTransaction.where_amount(amount: [10.dollars, 10.yens])
|
|
399
|
+
FinancialTransaction.where_amount(amount: 10.dollars..100.dollars)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
For decimal columns, raw numbers work:
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
SimpleOffer.where_amount(price: 50)
|
|
406
|
+
SimpleOffer.where_amount(price: 10..100)
|
|
407
|
+
```
|
|
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.
|
|
360
465
|
|
|
361
466
|
## Convenience methods
|
|
362
467
|
|
|
@@ -368,7 +473,7 @@ MoneyAttribute adds small helpers on `Numeric` and `String`:
|
|
|
368
473
|
12.euros # => [EUR 12.00]
|
|
369
474
|
```
|
|
370
475
|
|
|
371
|
-
> If you prefer not to extend core classes, use `
|
|
476
|
+
> If you prefer not to extend core classes, use `Money.from(12, 'USD')` instead.
|
|
372
477
|
|
|
373
478
|
## Form helpers
|
|
374
479
|
|
|
@@ -388,6 +493,8 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
|
|
|
388
493
|
|
|
389
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.
|
|
390
495
|
|
|
496
|
+
The accessor name must match the column name. `money_amount` does not support custom column mapping.
|
|
497
|
+
|
|
391
498
|
#### Migration helpers
|
|
392
499
|
|
|
393
500
|
| Method | Action |
|
|
@@ -438,15 +545,7 @@ money_amount :price
|
|
|
438
545
|
|
|
439
546
|
#### Querying
|
|
440
547
|
|
|
441
|
-
Fixed-currency attributes support Rails-native querying
|
|
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
|
-
```
|
|
548
|
+
Fixed-currency attributes support full Rails-native querying — see [Querying](#querying) for examples.
|
|
450
549
|
|
|
451
550
|
## Roadmap
|
|
452
551
|
|
data/Rakefile
CHANGED
|
@@ -15,13 +15,41 @@ 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
|
-
sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate', chdir: 'test/dummy')
|
|
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)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
desc 'Run tests (migrates test DB first)'
|
|
23
23
|
task test: %i[test_db_migrate test_run]
|
|
24
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
|
+
|
|
25
53
|
desc 'Run money_attribute vs money-rails benchmark'
|
|
26
54
|
task bench: :test_db_migrate do
|
|
27
55
|
puts
|
|
@@ -41,6 +69,6 @@ task bench: :test_db_migrate do
|
|
|
41
69
|
end
|
|
42
70
|
|
|
43
71
|
desc 'Generate consolidated benchmark report (markdown)'
|
|
44
|
-
task 'bench:report' do
|
|
72
|
+
task 'bench:report' do # rubocop:disable Rails/RakeEnvironment
|
|
45
73
|
ruby 'benchmark/report.rb'
|
|
46
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
|
-
#
|
|
10
|
-
# {currency: '
|
|
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
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# @private Constructor for integer (subunit) columns used by +composed_of+.
|
|
5
|
+
INTEGER_CONSTRUCTOR = lambda do |amount, currency|
|
|
6
|
+
next nil if amount.nil?
|
|
7
|
+
|
|
8
|
+
resolved = Money::Currency.resolve(currency.presence || MoneyAttribute.default_currency) || 'XXX'
|
|
9
|
+
Mint::Money.from_subunits(amount, resolved)
|
|
10
|
+
end.freeze
|
|
11
|
+
|
|
12
|
+
# @private Constructor for decimal (unit value) columns used by +composed_of+.
|
|
13
|
+
DECIMAL_CONSTRUCTOR = lambda do |amount, currency|
|
|
14
|
+
next nil if amount.nil?
|
|
15
|
+
|
|
16
|
+
resolved = Money::Currency.resolve(currency.presence || MoneyAttribute.default_currency) || 'XXX'
|
|
17
|
+
Mint::Money.from(amount, resolved)
|
|
18
|
+
end.freeze
|
|
19
|
+
|
|
20
|
+
# Value object holding metadata for a registered money attribute.
|
|
21
|
+
#
|
|
22
|
+
# Created by +money_attribute+ or +money_amount+ and stored in the class-level registry.
|
|
23
|
+
# Used by query helpers to resolve column names, build Money values, and generate SQL.
|
|
24
|
+
AttributeSpec = Struct.new(:name, :kind, :amount_col, :currency_col, :amount_type, keyword_init: true) do
|
|
25
|
+
# @return [Boolean] +true+ when the spec describes a two-column (amount + currency) attribute.
|
|
26
|
+
def composite? = kind == :composite
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] +true+ when the spec describes a single-column (fixed currency) attribute.
|
|
29
|
+
def single? = kind == :single
|
|
30
|
+
|
|
31
|
+
# Returns the backing database columns for the attribute.
|
|
32
|
+
#
|
|
33
|
+
# @return [Array<String>] two-element array for composite, one-element for single.
|
|
34
|
+
def columns
|
|
35
|
+
@columns ||= (composite? ? [amount_col, currency_col] : [amount_col]).freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Boolean] +true+ when the amount column stores subunits (bigint).
|
|
39
|
+
def integer_amount? = amount_type == :integer
|
|
40
|
+
|
|
41
|
+
# @return [Symbol] +:subunits+ for integer columns, +:to_d+ for decimal columns.
|
|
42
|
+
def amount_extractor = integer_amount? ? :subunits : :to_d
|
|
43
|
+
|
|
44
|
+
# @return [Hash{String => Symbol}] mapping suitable for +composed_of+.
|
|
45
|
+
def composed_of_mapping = { amount_col => amount_extractor, currency_col => :currency_code }
|
|
46
|
+
|
|
47
|
+
# @return [Proc] the constructor lambda used by +composed_of+ to instantiate Money values.
|
|
48
|
+
def constructor
|
|
49
|
+
integer_amount? ? INTEGER_CONSTRUCTOR : DECIMAL_CONSTRUCTOR
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Converts a raw amount and currency into a +Mint::Money+ value.
|
|
53
|
+
#
|
|
54
|
+
# @param amount [Integer, BigDecimal, nil] the raw column value
|
|
55
|
+
# @param currency [String, Mint::Currency, nil] the currency code or object
|
|
56
|
+
# @return [Mint::Money, nil] the resolved money value, or nil when amount is nil
|
|
57
|
+
def build_money(amount, currency)
|
|
58
|
+
return unless amount
|
|
59
|
+
return amount if amount.is_a?(Mint::Money)
|
|
60
|
+
|
|
61
|
+
constructor.call(amount, currency)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Normalizes a query value to the column's storage format.
|
|
65
|
+
#
|
|
66
|
+
# Composite attributes: decomposes +Mint::Money+ to subunits via +.subunits+.
|
|
67
|
+
# Single-column attributes: passes through (the registered Type handles serialization).
|
|
68
|
+
#
|
|
69
|
+
# @param value [Mint::Money, Numeric] the query value
|
|
70
|
+
# @return [Integer, BigDecimal, Mint::Money] the normalized value
|
|
71
|
+
def normalize_query_value(value)
|
|
72
|
+
return value unless integer_amount?
|
|
73
|
+
return value.subunits if value.is_a?(Mint::Money)
|
|
74
|
+
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent/map'
|
|
4
|
+
|
|
5
|
+
module MoneyAttribute
|
|
6
|
+
# Stores money attribute metadata on the model class.
|
|
7
|
+
module AttributeSpecRegistry
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
REGISTRY = Concurrent::Map.new
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
# Registers a money attribute spec for the current model class.
|
|
14
|
+
#
|
|
15
|
+
# @param name [Symbol, String] the attribute name
|
|
16
|
+
# @param kind [Symbol] +:composite+ or +:single+
|
|
17
|
+
# @param amount_col [Symbol, String] the amount column name
|
|
18
|
+
# @param currency_col [Symbol, String, nil] the currency column name (composite only)
|
|
19
|
+
# @param amount_type [Symbol, nil] +:integer+ or +:decimal+
|
|
20
|
+
# @return [AttributeSpec]
|
|
21
|
+
def register_money_attribute_spec(name, kind:, amount_col:, currency_col: nil, amount_type: nil)
|
|
22
|
+
spec = MoneyAttribute::AttributeSpec.new(
|
|
23
|
+
name: name.to_s,
|
|
24
|
+
kind: kind,
|
|
25
|
+
amount_col: amount_col.to_s,
|
|
26
|
+
currency_col: currency_col&.to_s,
|
|
27
|
+
amount_type: amount_type
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
money_attribute_specs[spec.name] = spec
|
|
31
|
+
spec
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the registered money attribute spec for the given name.
|
|
35
|
+
#
|
|
36
|
+
# @param name [Symbol, String] the attribute name
|
|
37
|
+
# @return [AttributeSpec, nil]
|
|
38
|
+
def money_attribute_spec(name)
|
|
39
|
+
REGISTRY[self]&.fetch(name.to_s, nil)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the registry hash for the current model class.
|
|
43
|
+
#
|
|
44
|
+
# @return [Hash{String => AttributeSpec}]
|
|
45
|
+
def money_attribute_specs
|
|
46
|
+
REGISTRY.fetch_or_store(self) { {} }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,27 +1,55 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# @private
|
|
5
|
+
CONFIG_MUTEX = Mutex.new
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
class << self
|
|
8
|
+
# Returns the lazily initialized gem configuration.
|
|
9
|
+
#
|
|
10
|
+
# @return [Config]
|
|
11
|
+
def config
|
|
12
|
+
CONFIG_MUTEX.synchronize { @config ||= Config.new }
|
|
10
13
|
end
|
|
11
|
-
end
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
# Yields the current configuration object for mutation.
|
|
16
|
+
#
|
|
17
|
+
# @yield [config] the current configuration
|
|
18
|
+
# @return [void]
|
|
19
|
+
def configure = yield config
|
|
20
|
+
|
|
21
|
+
# Returns the current request or default currency as a resolved currency.
|
|
22
|
+
# Memoized per-thread — within a request, Current.currency is stable.
|
|
23
|
+
#
|
|
24
|
+
# @return [Mint::Currency]
|
|
25
|
+
def default_currency
|
|
26
|
+
code = MoneyAttribute::Current.currency.presence || config.default_currency
|
|
15
27
|
|
|
16
|
-
|
|
28
|
+
last_code, last_currency = Thread.current[:money_attribute_default_currency]
|
|
29
|
+
return last_currency if last_code == code
|
|
17
30
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
currency = Money::Currency.resolve!(code)
|
|
32
|
+
Thread.current[:money_attribute_default_currency] = [code, currency]
|
|
33
|
+
currency
|
|
34
|
+
end
|
|
22
35
|
end
|
|
23
36
|
|
|
24
|
-
|
|
25
|
-
|
|
37
|
+
# Gem configuration holding the default currency and registered custom currencies.
|
|
38
|
+
#
|
|
39
|
+
# MoneyAttribute.configure do |config|
|
|
40
|
+
# config.default_currency = 'BRL'
|
|
41
|
+
# end
|
|
42
|
+
class Config
|
|
43
|
+
# @return [String] ISO 4217 currency code used when no per-request or per-row currency is set.
|
|
44
|
+
attr_accessor :default_currency
|
|
45
|
+
|
|
46
|
+
# @return [Array<Hash>] custom currencies registered via +register_custom_currencies!+.
|
|
47
|
+
attr_accessor :added_currencies
|
|
48
|
+
|
|
49
|
+
# Initializes the default gem configuration values.
|
|
50
|
+
def initialize
|
|
51
|
+
@default_currency = 'USD'
|
|
52
|
+
@added_currencies = []
|
|
53
|
+
end
|
|
26
54
|
end
|
|
27
55
|
end
|
|
@@ -3,15 +3,25 @@
|
|
|
3
3
|
module MoneyAttribute
|
|
4
4
|
# :nodoc:
|
|
5
5
|
class Converter
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
DEFAULT = new.freeze
|
|
7
|
+
|
|
8
|
+
# Initializes a converter with an optional fixed currency.
|
|
9
|
+
def initialize(currency = nil)
|
|
10
|
+
@static_currency = currency
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns the shared default converter instance.
|
|
14
|
+
def self.default
|
|
15
|
+
DEFAULT
|
|
8
16
|
end
|
|
9
17
|
|
|
18
|
+
# Converts raw input into a `Money` value.
|
|
10
19
|
def parse(amount)
|
|
20
|
+
currency = @static_currency || MoneyAttribute.default_currency
|
|
11
21
|
case amount
|
|
12
22
|
when Money, NilClass then amount
|
|
13
|
-
when Numeric then Money.from(amount,
|
|
14
|
-
when String then Money.parse(amount,
|
|
23
|
+
when Numeric then Money.from(amount, currency)
|
|
24
|
+
when String then Money.parse(amount, currency)
|
|
15
25
|
else raise ArgumentError, "Cannot convert #{amount.inspect} (#{amount.class}) to Money"
|
|
16
26
|
end
|
|
17
27
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/current_attributes'
|
|
4
|
+
|
|
5
|
+
module MoneyAttribute
|
|
6
|
+
# Per-request currency container. Set MoneyAttribute::Current.currency in your
|
|
7
|
+
# controller (or a before_action) to override the configured default for that request.
|
|
8
|
+
# Automatically reset after each request by MoneyAttribute::Middleware.
|
|
9
|
+
class Current < ::ActiveSupport::CurrentAttributes
|
|
10
|
+
# @return [String, nil] per-request ISO 4217 currency code override.
|
|
11
|
+
attribute :currency
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module MoneyAttribute
|
|
4
4
|
# :nodoc:
|
|
5
5
|
module FormBuilderExtension
|
|
6
|
+
# Renders a text input for a composed money attribute.
|
|
6
7
|
def money_field(method, options = {})
|
|
7
8
|
money = object.public_send(method)
|
|
8
9
|
value = money&.to_fs
|
|
@@ -11,6 +12,7 @@ module MoneyAttribute
|
|
|
11
12
|
{ id: field_id(method) }.merge(options))
|
|
12
13
|
end
|
|
13
14
|
|
|
15
|
+
# Renders a number input for a single-column money attribute.
|
|
14
16
|
def money_amount_field(method, options = {})
|
|
15
17
|
money_from_column = object.public_send(method)
|
|
16
18
|
value = money_from_column&.to_d
|