money_attribute 1.1.0 → 1.2.1

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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +143 -41
  3. data/Rakefile +43 -2
  4. data/lib/generators/money_attribute/initializer_generator.rb +10 -0
  5. data/lib/generators/templates/money_attribute.rb +20 -12
  6. data/lib/money_attribute/attribute_spec.rb +84 -0
  7. data/lib/money_attribute/attribute_spec_registry.rb +78 -0
  8. data/lib/money_attribute/column_type_validations.rb +47 -0
  9. data/lib/money_attribute/configuration.rb +43 -15
  10. data/lib/money_attribute/converter.rb +30 -5
  11. data/lib/money_attribute/core_ext/numeric.rb +17 -0
  12. data/lib/money_attribute/core_ext/string.rb +11 -1
  13. data/lib/money_attribute/current.rb +13 -0
  14. data/lib/money_attribute/form_builder_extension.rb +48 -1
  15. data/lib/money_attribute/macro.rb +106 -72
  16. data/lib/money_attribute/migration_extensions/helper.rb +62 -3
  17. data/lib/money_attribute/migration_extensions/schema_statements.rb +89 -9
  18. data/lib/money_attribute/migration_extensions/table_definition.rb +68 -11
  19. data/lib/money_attribute/money_amount.rb +49 -25
  20. data/lib/money_attribute/query/amount_condition.rb +207 -0
  21. data/lib/money_attribute/query/amount_order.rb +28 -0
  22. data/lib/money_attribute/query/currency_condition.rb +29 -0
  23. data/lib/money_attribute/query/helpers.rb +53 -0
  24. data/lib/money_attribute/query/pick.rb +41 -0
  25. data/lib/money_attribute/query/pluck.rb +39 -0
  26. data/lib/money_attribute/query/sum.rb +52 -0
  27. data/lib/money_attribute/query.rb +125 -0
  28. data/lib/money_attribute/railtie.rb +47 -1
  29. data/lib/money_attribute/type.rb +41 -33
  30. data/lib/money_attribute/version.rb +2 -1
  31. data/lib/money_attribute.rb +16 -1
  32. metadata +17 -5
  33. data/lib/money_attribute/core_ext.rb +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bcfa5f42f59d3a7f372ddbff422a4e2aa45faccf4f5bd912fc2509e974bda609
4
- data.tar.gz: 173509ac929dc32881a8bae54be2c3a7c0f92632ab8fae0192d84ae9b42433eb
3
+ metadata.gz: a26cda78a2361b592ca6f90c1afb5f8637792b432038d45b543aa562624d6349
4
+ data.tar.gz: 0e9d78eaf7c8118942676f81f4dbebe55614663469d0d8f3095622fc0def3dca
5
5
  SHA512:
6
- metadata.gz: 18598feb924b703ae70e0fe68a76dd93d3ec9f22753d8556616d8c42ebae8460512e9451ed2e80b96feb44d380e8c03e1d2c0a23f22cf956773d001f5e27d83b
7
- data.tar.gz: 1178b4b944cbd54b93d8d96f03a55403141086573d5f15f6225a820e65fb9a3e4fae7f0277518563274fa91a7dc7fd6a4ae889e1391dfb69efe72c07de0bb53a
6
+ metadata.gz: 4ba8368b5c3c11be2375368399113b1526b1e2d9f347b2e5d16499a64577dffb5b846a876ba4d04967e8ac8a76fccc717aafb8caaefd25c83bb7dc61ce22839e
7
+ data.tar.gz: 52dd36ef69c80695f8e22802e5486332d94d5afcc4e48cf671847705b3120de5ecfb636b191fe629077d8f6ebb35a2dcbd5b6a716f031bdb918a35abe32d23fa
data/README.md CHANGED
@@ -3,9 +3,9 @@
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 no manual serialization.
6
+ Store and read Active Record attributes as `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--money_amount-fixed-currency)).
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
@@ -32,8 +32,7 @@ p.price * 2 # => [USD 24.88]
32
32
  - [Convenience methods](#convenience-methods)
33
33
  - [Form helpers](#form-helpers)
34
34
  - [Roadmap](#roadmap)
35
- - [Development & Contributing](#development)
36
- - [License](#license)
35
+ - [Development](#development)
37
36
 
38
37
  ## Quick start
39
38
 
@@ -62,13 +61,13 @@ class Product < ApplicationRecord
62
61
  end
63
62
  ```
64
63
 
65
- That's it. `Product.new(price: 12.dollars).price` is a `Mint::Money`.
64
+ That's it. `Product.new(price: 12.dollars).price` is a `Money`.
66
65
 
67
66
  ## Why MoneyAttribute?
68
67
 
69
- - **No serialization boilerplate** — declare once, read/write `Mint::Money` everywhere.
68
+ - **No serialization boilerplate** — declare once, read/write `Money` everywhere.
70
69
  - **Integer or decimal columns** — auto-detects the column type and adjusts serialization (e.g. integer stores cents, decimal stores unit value).
71
- - **Normalizes everything** — pass a number, string, or `Mint::Money`; always get a `Mint::Money` back.
70
+ - **Normalizes everything** — pass a number, string, or `Money`; always get a `Money` back.
72
71
  - **Currency enforcement** — fixed-currency attributes reject wrong currencies at assignment time.
73
72
  - **Built on Rails primitives** — uses `ActiveRecord::Type`, `composed_of`, and `normalizes` under the hood. No monkey-patching of core classes.
74
73
 
@@ -93,7 +92,7 @@ For a detailed side-by-side comparison, see [COMPARISON.md](COMPARISON.md).
93
92
 
94
93
  - Ruby 3.3+
95
94
  - Rails 7.1.3.2+
96
- - [Minting](https://github.com/gferraz/minting) 2.0+
95
+ - [Minting](https://github.com/gferraz/minting) 2.1+
97
96
 
98
97
  ## Installation
99
98
 
@@ -188,16 +187,16 @@ MoneyAttribute integrates with Rails I18n to automatically format money amounts
188
187
 
189
188
  With `I18n.locale` set to `:en`:
190
189
  ```ruby
191
- Mint.money(1234.56, 'USD').to_s # => "$1,234.56"
190
+ Money.from(1234.56, 'USD').to_s # => "$1,234.56"
192
191
  ```
193
192
 
194
193
  Switch to `:'pt-BR'` and the separators change automatically (requires [`rails-i18n`](https://github.com/svenfuchs/rails-i18n) or your own locale file):
195
194
  ```ruby
196
195
  I18n.locale = :'pt-BR'
197
- Mint.money(1234.56, 'USD').to_s # => "$1.234,56"
196
+ Money.from(1234.56, 'USD').to_s # => "$1.234,56"
198
197
  ```
199
198
 
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).
199
+ The locale backend reads `number.currency.format` from your I18n translations and maps Rails format syntax (`%n` for amount, `%u` for unit) to `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).
201
200
 
202
201
  You can configure per-sign formatting by adding `positive`, `negative`, and `zero` keys to your locale:
203
202
 
@@ -218,9 +217,9 @@ en:
218
217
  When any of `positive`, `negative`, or `zero` is present, a Hash format is built. Missing keys fall back to `format`:
219
218
 
220
219
  ```ruby
221
- Mint.money(1234.56, 'USD').to_s # => "$1,234.56"
222
- Mint.money(-1234.56, 'USD').to_s # => "($1,234.56)"
223
- Mint.money(0, 'USD').to_s # => "--"
220
+ Money.from(1234.56, 'USD').to_s # => "$1,234.56"
221
+ Money.from(-1234.56, 'USD').to_s # => "($1,234.56)"
222
+ Money.from(0, 'USD').to_s # => "--"
224
223
  ```
225
224
 
226
225
  If none of those keys are set, `format` is used as a plain string (simple formatting).
@@ -304,16 +303,24 @@ end
304
303
 
305
304
  ## Column resolution
306
305
 
307
- `money_attribute :name` is always composite. It resolves columns in this order:
306
+ `money_attribute :name` is always composite. Columns are resolved in two phases:
308
307
 
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) |
308
+ **Phase 1 Default mapping** is determined by which columns exist:
309
+
310
+ | Condition | Default columns |
311
+ |---|---|
312
+ | `name_currency` exists AND `name` exists | `name` + `name_currency` |
313
+ | `name == 'amount'` AND `currency` exists | `amount` + `currency` |
314
+ | Otherwise | `<name>_amount` + `<name>_currency` |
315
+
316
+ **Phase 2 — Override** via `mapping:` is merged on top of the default. Missing keys inherit from the default mapping:
317
+
318
+ ```ruby
319
+ money_attribute :total, mapping: { amount: :total_amount }
320
+ # currency falls back to default => :total_currency
321
+ ```
315
322
 
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).
323
+ Raises `ArgumentError` if the resolved columns don't exist. For single-column fixed-currency attributes, see [`money_amount`](#single-column-mode-money_amount-fixed-currency).
317
324
 
318
325
  **Example**
319
326
 
@@ -343,6 +350,8 @@ end
343
350
 
344
351
  ## Querying
345
352
 
353
+ ### Rails-native queries
354
+
346
355
  Multi-currency (`money_attribute`) attributes support equality queries via `composed_of`:
347
356
 
348
357
  ```ruby
@@ -356,7 +365,110 @@ Offer.where(price_amount: 10..20, price_currency: 'EUR')
356
365
  Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')
357
366
  ```
358
367
 
359
- For fixed-currency (`money_amount`) attributes, see the [single-column section](#single-column-mode--money_amount).
368
+ Fixed-currency (`money_amount`) attributes support full Rails-native querying through the custom type — equality, IN, BETWEEN, ordering, and aggregation all work:
369
+
370
+ ```ruby
371
+ Product.where(price: 10.to_money('USD')) # equality
372
+ Product.where(price: [10.to_money('USD'), 20.to_money('USD')]) # IN
373
+ Product.where(price: 10.to_money('USD')..20.to_money('USD')) # BETWEEN
374
+ Product.order(price: :desc) # ordering
375
+ Product.where(price: 10.to_money('USD')).sum(:price) # aggregation
376
+ ```
377
+
378
+ ### Money-aware query helpers
379
+
380
+ For multi-currency attributes, manually decomposing columns is tedious. The query helpers handle this automatically — just pass Money objects:
381
+
382
+ #### `where_amount`
383
+
384
+ Filters by amount value. Accepts a scalar, Range, or Array.
385
+
386
+ ```ruby
387
+ Offer.where_amount(price: 10) # equality (any currency)
388
+ Offer.where_amount(price: [10, 30]) # IN — matches EUR 10, USD 30
389
+ Offer.where_amount(price: 10..100) # BETWEEN (inclusive)
390
+ Offer.where_amount(price: 10...100) # BETWEEN (exclusive upper bound)
391
+ ```
392
+
393
+ Ranges work across currencies — `10..50` matches EUR 10 and USD 50:
394
+
395
+ ```ruby
396
+ Offer.create!(price: 10.euros)
397
+ Offer.create!(price: 50.dollars)
398
+
399
+ Offer.where_amount(price: 10..50) # => both records
400
+ ```
401
+
402
+ For integer (subunit) columns, pass `Money` objects directly — subunit conversion is handled automatically:
403
+
404
+ ```ruby
405
+ FinancialTransaction.where_amount(amount: [10.dollars, 10.yens])
406
+ FinancialTransaction.where_amount(amount: 10.dollars..100.dollars)
407
+ ```
408
+
409
+ For decimal columns, raw numbers work:
410
+
411
+ ```ruby
412
+ SimpleOffer.where_amount(price: 50)
413
+ SimpleOffer.where_amount(price: 10..100)
414
+ ```
415
+
416
+ #### `where_currency`
417
+
418
+ Filters by currency code. Composite attributes only — raises `ArgumentError` for single-column attributes.
419
+
420
+ ```ruby
421
+ Offer.where_currency(price: 'EUR')
422
+ Offer.where_currency(price: 10.euros.currency) # also accepts Currency object
423
+ ```
424
+
425
+ #### `order_by_amount`
426
+
427
+ Orders by amount. Composite attributes sort by currency ASC first, then amount. Single-column attributes sort by amount only.
428
+
429
+ ```ruby
430
+ Offer.order_by_amount(price: :asc) # EUR 10, EUR 100, USD 50
431
+ Offer.order_by_amount(price: :desc) # EUR 100, EUR 10, USD 50
432
+ Offer.order_by_amount(price: nil) # defaults to :asc
433
+ ```
434
+
435
+ #### `pluck_amount`
436
+
437
+ Returns money-aware amounts. Follows Rails' `pluck` arity — one attribute returns flat values, multiple attributes return row arrays.
438
+
439
+ ```ruby
440
+ Offer.pluck_amount(:price) # => [EUR 10.00, USD 20.00]
441
+ Offer.pluck_amount(:amount, :discount) # => [[USD 100.00, EUR 20.00], ...]
442
+ ```
443
+
444
+ #### `pick_amount`
445
+
446
+ Returns a single money-aware value. Follows Rails' `pick` arity.
447
+
448
+ ```ruby
449
+ Offer.pick_amount(:price) # => EUR 10.00
450
+ Offer.pick_amount(:amount, :discount) # => [USD 100.00, EUR 20.00]
451
+ Offer.none.pick_amount(:price) # => nil
452
+ ```
453
+
454
+ #### `sum_amount`
455
+
456
+ Sums amounts grouped by currency for composite attributes. Accepts a single attribute name only.
457
+
458
+ ```ruby
459
+ Offer.sum_amount(:price)
460
+ # => [EUR 30.00, USD 70.00] (one Money per currency, sorted by code)
461
+
462
+ SimpleOffer.sum_amount(:price)
463
+ # => [BRL 60.00] (single-column always returns one Money)
464
+
465
+ Offer.none.sum_amount(:price)
466
+ # => [BRL 0.00] (empty result returns zero Money)
467
+ ```
468
+
469
+ ### Notes
470
+
471
+ 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
472
 
361
473
  ## Convenience methods
362
474
 
@@ -368,7 +480,7 @@ MoneyAttribute adds small helpers on `Numeric` and `String`:
368
480
  12.euros # => [EUR 12.00]
369
481
  ```
370
482
 
371
- > If you prefer not to extend core classes, use `Mint.money(12, 'USD')` instead.
483
+ > If you prefer not to extend core classes, use `Money.from(12, 'USD')` instead.
372
484
 
373
485
  ## Form helpers
374
486
 
@@ -386,7 +498,9 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
386
498
 
387
499
  ### Single-column mode — `money_amount` (fixed-currency)
388
500
 
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.
501
+ `money_amount` wraps a numeric column as `Money` using the application's default currency. No per-row currency. A lighter alternative when you don't need multi-currency support.
502
+
503
+ The accessor name must match the column name. `money_amount` does not support custom column mapping.
390
504
 
391
505
  #### Migration helpers
392
506
 
@@ -398,9 +512,9 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
398
512
  Default column: `decimal(20,4)`. The top-level `type:` shortcut selects the column type:
399
513
 
400
514
  ```ruby
401
- t.money_amount :price # decimal(20,4)
515
+ t.money_amount :price # decimal(20,4)
402
516
  t.money_amount :btc_balance, type: :crypto_decimal # decimal(36,18)
403
- t.money_amount :qty, type: :fiat_integer # bigint
517
+ t.money_amount :qty, type: :fiat_integer # bigint
404
518
  ```
405
519
 
406
520
  #### Naming
@@ -438,15 +552,7 @@ money_amount :price
438
552
 
439
553
  #### Querying
440
554
 
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
- ```
555
+ Fixed-currency attributes support full Rails-native querying see [Querying](#querying) for examples.
450
556
 
451
557
  ## Roadmap
452
558
 
@@ -465,8 +571,4 @@ The dummy Rails app under `test/dummy` exercises the engine in a full Rails envi
465
571
 
466
572
  ## Contributing
467
573
 
468
- Bug reports and pull requests welcome at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
469
-
470
- ## License
471
-
472
- [MIT](MIT-LICENSE)
574
+ Bug reports welcome at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
data/Rakefile CHANGED
@@ -16,13 +16,41 @@ end
16
16
 
17
17
  desc 'Migrate test database'
18
18
  task :test_db_migrate do
19
- sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate', chdir: 'test/dummy')
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
- desc 'Run money_attribute vs money-rails benchmark'
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
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
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
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
+
53
+ desc 'Run money_attribute vs plain Rails vs money-rails benchmark'
26
54
  task bench: :test_db_migrate do
27
55
  puts
28
56
  puts '=' * 80
@@ -31,6 +59,13 @@ task bench: :test_db_migrate do
31
59
  sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'minting' },
32
60
  'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
33
61
 
62
+ puts
63
+ puts '=' * 80
64
+ puts 'plain Rails (raw columns, no monetization)'
65
+ puts '=' * 80
66
+ sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'plain' },
67
+ 'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
68
+
34
69
  puts
35
70
  puts '=' * 80
36
71
  puts 'money-rails (money gem)'
@@ -44,3 +79,9 @@ desc 'Generate consolidated benchmark report (markdown)'
44
79
  task 'bench:report' do
45
80
  ruby 'benchmark/report.rb'
46
81
  end
82
+
83
+ desc 'Profile hot paths with stackprof (MODE=string_query|pluck|read_cached|multi_record|arithmetic|all)'
84
+ task 'bench:profile' do
85
+ mode = ENV['MODE'] || 'all'
86
+ sh({ 'RAILS_ENV' => 'test' }, 'bundle', 'exec', 'ruby', 'benchmark/profile.rb', mode)
87
+ end
@@ -1,12 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
+ # Generator namespace for money_attribute.
4
5
  module Generators
6
+ # Rails generator that installs the money_attribute initializer.
7
+ #
8
+ # @example
9
+ # rails generate money_attribute:initializer
10
+ #
11
+ # @api private
5
12
  class InitializerGenerator < ::Rails::Generators::Base
6
13
  source_root File.expand_path('../templates', __dir__)
7
14
 
8
15
  desc 'Creates MoneyAttribute initializer.'
9
16
 
17
+ # Copies the initializer template into the application.
18
+ #
19
+ # @return [void]
10
20
  def copy_initializer
11
21
  copy_file 'money_attribute.rb', 'config/initializers/money_attribute.rb'
12
22
  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
@@ -0,0 +1,84 @@
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
+ #
25
+ # @api private
26
+ AttributeSpec = Struct.new(:name, :kind, :amount_column, :currency_column, :amount_type, keyword_init: true) do
27
+ # @return [Boolean] +true+ when the spec describes a two-column (amount + currency) attribute.
28
+ def composite? = kind == :composite
29
+
30
+ # @return [Boolean] +true+ when the spec describes a single-column (fixed currency) attribute.
31
+ def single? = kind == :single
32
+
33
+ # Returns the backing database columns for the attribute.
34
+ #
35
+ # @return [Array<String>] two-element array for composite, one-element for single.
36
+ def columns
37
+ @columns ||= (composite? ? [amount_column, currency_column] : [amount_column]).freeze
38
+ end
39
+
40
+ # @return [Boolean] +true+ when the amount column stores subunits (bigint).
41
+ def integer_amount? = amount_type == :integer
42
+
43
+ # @return [Symbol] +:subunits+ for integer columns, +:to_d+ for decimal columns.
44
+ def amount_extractor = integer_amount? ? :subunits : :to_d
45
+
46
+ # @return [Hash{String => Symbol}] mapping suitable for +composed_of+.
47
+ def composed_of_mapping
48
+ @composed_of_mapping ||= { amount_column => amount_extractor, currency_column => :currency_code }.freeze
49
+ end
50
+
51
+ # @return [Proc] the constructor lambda used by +composed_of+ to instantiate Money values.
52
+ def constructor
53
+ integer_amount? ? INTEGER_CONSTRUCTOR : DECIMAL_CONSTRUCTOR
54
+ end
55
+
56
+ # Converts a raw amount and currency into a +Mint::Money+ value.
57
+ #
58
+ # @param amount [Integer, BigDecimal, nil] the raw column value
59
+ # @param currency [String, Mint::Currency, nil] the currency code or object
60
+ # @return [Mint::Money, nil] the resolved money value, or nil when amount is nil
61
+ # @api private
62
+ def build_money(amount, currency)
63
+ return unless amount
64
+ return amount if amount.is_a?(Mint::Money)
65
+
66
+ constructor.call(amount, currency)
67
+ end
68
+
69
+ # Normalizes a query value to the column's storage format.
70
+ #
71
+ # Composite attributes: decomposes +Mint::Money+ to subunits via +.subunits+.
72
+ # Single-column attributes: passes through (the registered Type handles serialization).
73
+ #
74
+ # @param value [Mint::Money, Numeric] the query value
75
+ # @return [Integer, BigDecimal, Mint::Money] the normalized value
76
+ # @api private
77
+ def normalize_query_value(value)
78
+ return value unless integer_amount?
79
+ return value.subunits if value.is_a?(Mint::Money)
80
+
81
+ value
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/map'
4
+
5
+ module MoneyAttribute
6
+ # Stores money attribute metadata on the model class.
7
+ #
8
+ # Holds a per-model registry of {AttributeSpec} objects plus derived caches
9
+ # (name set, regex patterns) populated lazily on first access.
10
+ #
11
+ # @api private
12
+ module AttributeSpecRegistry
13
+ extend ActiveSupport::Concern
14
+
15
+ REGISTRY = Concurrent::Map.new
16
+ PATTERNS = Concurrent::Map.new
17
+
18
+ class_methods do
19
+ # Registers a money attribute spec for the current model class.
20
+ #
21
+ # @param name [Symbol, String] the attribute name
22
+ # @param kind [Symbol] +:composite+ or +:single+
23
+ # @param amount_column [Symbol, String] the amount column name
24
+ # @param currency_column [Symbol, String, nil] the currency column name (composite only)
25
+ # @param amount_type [Symbol, nil] +:integer+ or +:decimal+
26
+ # @return [AttributeSpec]
27
+ # @api private
28
+ def register_money_attribute_spec(name, kind:, amount_column:, currency_column: nil, amount_type: nil)
29
+ spec = MoneyAttribute::AttributeSpec.new(
30
+ name: name.to_s,
31
+ kind: kind,
32
+ amount_column: amount_column.to_s,
33
+ currency_column: currency_column&.to_s,
34
+ amount_type: amount_type
35
+ )
36
+
37
+ money_attribute_specs[spec.name] = spec
38
+ spec
39
+ end
40
+
41
+ # Returns the registered money attribute spec for the given name.
42
+ #
43
+ # @param name [Symbol, String] the attribute name
44
+ # @return [AttributeSpec, nil]
45
+ # @api private
46
+ def money_attribute_spec(name)
47
+ REGISTRY[self]&.fetch(name.to_s, nil)
48
+ end
49
+
50
+ # Returns the registry hash for the current model class.
51
+ #
52
+ # @return [Hash{String => AttributeSpec}]
53
+ # @api private
54
+ def money_attribute_specs
55
+ REGISTRY.fetch_or_store(self) { {} }
56
+ end
57
+
58
+ # Returns a frozen Set of registered money attribute names.
59
+ #
60
+ # @return [Set<String>]
61
+ # @api private
62
+ def money_attribute_names_set
63
+ PATTERNS.fetch_or_store(:"#{self}_name_set") { money_attribute_specs.keys.to_set.freeze }
64
+ end
65
+
66
+ # Returns a pre-compiled regex matching any registered money attribute name.
67
+ #
68
+ # @return [Regexp]
69
+ # @api private
70
+ def money_attribute_name_pattern
71
+ PATTERNS.fetch_or_store(:"#{self}_name_pattern") do
72
+ names = money_attribute_specs.keys.map { |n| Regexp.escape(n) }
73
+ /\b(#{names.join('|')})\b/i
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # Shared column-type validation for the +money_attribute+ and +money_amount+
5
+ # macros.
6
+ #
7
+ # Included by {Macro::CompositeClassMethods} and {MoneyAmount}. Raises when a
8
+ # backing column uses a type that cannot store a money amount or currency code.
9
+ #
10
+ # @api private
11
+ module ColumnTypeValidations
12
+ VALID_AMOUNT_TYPES = %i[integer bigint decimal].freeze
13
+ VALID_CURRENCY_TYPES = %i[string text].freeze
14
+
15
+ # Validates that a column can store a money amount.
16
+ #
17
+ # @param attr_name [Symbol, String] the money attribute accessor name
18
+ # @param column_name [Symbol, String] the amount column name
19
+ # @param column [ActiveRecord::ConnectionAdapters::Column] the column metadata
20
+ # @return [void]
21
+ # @raise [ArgumentError] if the column type is not numeric
22
+ # @api private
23
+ def assert_valid_amount_column!(attr_name, column_name, column)
24
+ return if VALID_AMOUNT_TYPES.include?(column.type)
25
+
26
+ raise ArgumentError,
27
+ "`:#{attr_name}` amount column `#{column_name}` must be a numeric type " \
28
+ "(integer, bigint, or decimal), got `#{column.type}`"
29
+ end
30
+
31
+ # Validates that a column can store a currency code.
32
+ #
33
+ # @param attr_name [Symbol, String] the money attribute accessor name
34
+ # @param column_name [Symbol, String] the currency column name
35
+ # @param column [ActiveRecord::ConnectionAdapters::Column] the column metadata
36
+ # @return [void]
37
+ # @raise [ArgumentError] if the column type is not string-like
38
+ # @api private
39
+ def assert_valid_currency_column!(attr_name, column_name, column)
40
+ return if VALID_CURRENCY_TYPES.include?(column.type)
41
+
42
+ raise ArgumentError,
43
+ "`:#{attr_name}` currency column `#{column_name}` must be a string type " \
44
+ "(string or text), got `#{column.type}`"
45
+ end
46
+ end
47
+ end