money_attribute 1.2.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.
- checksums.yaml +4 -4
- data/README.md +29 -26
- data/Rakefile +19 -6
- data/lib/generators/money_attribute/initializer_generator.rb +10 -0
- data/lib/money_attribute/attribute_spec.rb +9 -3
- data/lib/money_attribute/attribute_spec_registry.rb +33 -5
- data/lib/money_attribute/column_type_validations.rb +47 -0
- data/lib/money_attribute/converter.rb +17 -2
- data/lib/money_attribute/core_ext/numeric.rb +10 -1
- data/lib/money_attribute/core_ext/string.rb +11 -1
- data/lib/money_attribute/form_builder_extension.rb +48 -3
- data/lib/money_attribute/macro.rb +63 -6
- data/lib/money_attribute/migration_extensions/helper.rb +61 -1
- data/lib/money_attribute/migration_extensions/schema_statements.rb +89 -9
- data/lib/money_attribute/migration_extensions/table_definition.rb +68 -11
- data/lib/money_attribute/money_amount.rb +30 -2
- data/lib/money_attribute/query/amount_condition.rb +161 -2
- data/lib/money_attribute/query/amount_order.rb +9 -3
- data/lib/money_attribute/query/currency_condition.rb +8 -2
- data/lib/money_attribute/query/helpers.rb +28 -5
- data/lib/money_attribute/query/pick.rb +11 -8
- data/lib/money_attribute/query/pluck.rb +11 -15
- data/lib/money_attribute/query/sum.rb +15 -3
- data/lib/money_attribute/query.rb +16 -11
- data/lib/money_attribute/railtie.rb +40 -0
- data/lib/money_attribute/type.rb +1 -1
- data/lib/money_attribute/version.rb +2 -1
- data/lib/money_attribute.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a26cda78a2361b592ca6f90c1afb5f8637792b432038d45b543aa562624d6349
|
|
4
|
+
data.tar.gz: 0e9d78eaf7c8118942676f81f4dbebe55614663469d0d8f3095622fc0def3dca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ba8368b5c3c11be2375368399113b1526b1e2d9f347b2e5d16499a64577dffb5b846a876ba4d04967e8ac8a76fccc717aafb8caaefd25c83bb7dc61ce22839e
|
|
7
|
+
data.tar.gz: 52dd36ef69c80695f8e22802e5486332d94d5afcc4e48cf671847705b3120de5ecfb636b191fe629077d8f6ebb35a2dcbd5b6a716f031bdb918a35abe32d23fa
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/gferraz/money-attribute/actions/workflows/ci.yml)
|
|
4
4
|
[](https://badge.fury.io/rb/money_attribute)
|
|
5
5
|
|
|
6
|
-
Store and read Active Record attributes as `
|
|
6
|
+
Store and read Active Record attributes as `Money` objects with no manual serialization.
|
|
7
7
|
|
|
8
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
|
|
|
@@ -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
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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.
|
|
95
|
+
- [Minting](https://github.com/gferraz/minting) 2.1+
|
|
97
96
|
|
|
98
97
|
## Installation
|
|
99
98
|
|
|
@@ -197,7 +196,7 @@ I18n.locale = :'pt-BR'
|
|
|
197
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 `
|
|
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
|
|
|
@@ -304,16 +303,24 @@ end
|
|
|
304
303
|
|
|
305
304
|
## Column resolution
|
|
306
305
|
|
|
307
|
-
`money_attribute :name` is always composite.
|
|
306
|
+
`money_attribute :name` is always composite. Columns are resolved in two phases:
|
|
308
307
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
|
312
|
-
|
|
313
|
-
|
|
|
314
|
-
|
|
|
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
315
|
|
|
316
|
-
|
|
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
|
+
```
|
|
322
|
+
|
|
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
|
|
|
@@ -392,7 +399,7 @@ Offer.create!(price: 50.dollars)
|
|
|
392
399
|
Offer.where_amount(price: 10..50) # => both records
|
|
393
400
|
```
|
|
394
401
|
|
|
395
|
-
For integer (subunit) columns, pass `
|
|
402
|
+
For integer (subunit) columns, pass `Money` objects directly — subunit conversion is handled automatically:
|
|
396
403
|
|
|
397
404
|
```ruby
|
|
398
405
|
FinancialTransaction.where_amount(amount: [10.dollars, 10.yens])
|
|
@@ -456,7 +463,7 @@ SimpleOffer.sum_amount(:price)
|
|
|
456
463
|
# => [BRL 60.00] (single-column always returns one Money)
|
|
457
464
|
|
|
458
465
|
Offer.none.sum_amount(:price)
|
|
459
|
-
# => [
|
|
466
|
+
# => [BRL 0.00] (empty result returns zero Money)
|
|
460
467
|
```
|
|
461
468
|
|
|
462
469
|
### Notes
|
|
@@ -491,7 +498,7 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
|
|
|
491
498
|
|
|
492
499
|
### Single-column mode — `money_amount` (fixed-currency)
|
|
493
500
|
|
|
494
|
-
`money_amount` wraps a numeric column as `
|
|
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.
|
|
495
502
|
|
|
496
503
|
The accessor name must match the column name. `money_amount` does not support custom column mapping.
|
|
497
504
|
|
|
@@ -505,9 +512,9 @@ The accessor name must match the column name. `money_amount` does not support cu
|
|
|
505
512
|
Default column: `decimal(20,4)`. The top-level `type:` shortcut selects the column type:
|
|
506
513
|
|
|
507
514
|
```ruby
|
|
508
|
-
t.money_amount :price
|
|
515
|
+
t.money_amount :price # decimal(20,4)
|
|
509
516
|
t.money_amount :btc_balance, type: :crypto_decimal # decimal(36,18)
|
|
510
|
-
t.money_amount :qty,
|
|
517
|
+
t.money_amount :qty, type: :fiat_integer # bigint
|
|
511
518
|
```
|
|
512
519
|
|
|
513
520
|
#### Naming
|
|
@@ -564,8 +571,4 @@ The dummy Rails app under `test/dummy` exercises the engine in a full Rails envi
|
|
|
564
571
|
|
|
565
572
|
## Contributing
|
|
566
573
|
|
|
567
|
-
Bug reports
|
|
568
|
-
|
|
569
|
-
## License
|
|
570
|
-
|
|
571
|
-
[MIT](MIT-LICENSE)
|
|
574
|
+
Bug reports welcome at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
|
data/Rakefile
CHANGED
|
@@ -15,7 +15,7 @@ Rake::TestTask.new(:test_run) do |t|
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
desc 'Migrate test database'
|
|
18
|
-
task :test_db_migrate do
|
|
18
|
+
task :test_db_migrate do
|
|
19
19
|
sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -27,13 +27,13 @@ task test: %i[test_db_migrate test_run]
|
|
|
27
27
|
%w[postgresql mysql2].each do |adapter|
|
|
28
28
|
namespace :test do
|
|
29
29
|
desc "Migrate test database (#{adapter})"
|
|
30
|
-
task "db_migrate:#{adapter}" do
|
|
30
|
+
task "db_migrate:#{adapter}" do
|
|
31
31
|
sh({ 'RAILS_ENV' => 'test', 'DATABASE_ADAPTER' => adapter },
|
|
32
32
|
'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
desc "Run tests against #{adapter}"
|
|
36
|
-
task adapter do
|
|
36
|
+
task adapter do
|
|
37
37
|
Rake::Task[:"test:db_migrate:#{adapter}"].invoke
|
|
38
38
|
sh({ 'DATABASE_ADAPTER' => adapter }, 'bundle', 'exec', 'rake', 'test_run')
|
|
39
39
|
end
|
|
@@ -41,7 +41,7 @@ task test: %i[test_db_migrate test_run]
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
desc 'Run tests against all adapters (sqlite3, postgresql, mysql2)'
|
|
44
|
-
task 'test:all' do
|
|
44
|
+
task 'test:all' do
|
|
45
45
|
Rake::Task[:test].invoke
|
|
46
46
|
%w[postgresql mysql2].each do |adapter|
|
|
47
47
|
Rake::Task[:"test:#{adapter}"].invoke
|
|
@@ -50,7 +50,7 @@ end
|
|
|
50
50
|
|
|
51
51
|
# --- Benchmark ---
|
|
52
52
|
|
|
53
|
-
desc 'Run money_attribute vs money-rails benchmark'
|
|
53
|
+
desc 'Run money_attribute vs plain Rails vs money-rails benchmark'
|
|
54
54
|
task bench: :test_db_migrate do
|
|
55
55
|
puts
|
|
56
56
|
puts '=' * 80
|
|
@@ -59,6 +59,13 @@ task bench: :test_db_migrate do
|
|
|
59
59
|
sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'minting' },
|
|
60
60
|
'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
|
|
61
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
|
+
|
|
62
69
|
puts
|
|
63
70
|
puts '=' * 80
|
|
64
71
|
puts 'money-rails (money gem)'
|
|
@@ -69,6 +76,12 @@ task bench: :test_db_migrate do
|
|
|
69
76
|
end
|
|
70
77
|
|
|
71
78
|
desc 'Generate consolidated benchmark report (markdown)'
|
|
72
|
-
task 'bench:report' do
|
|
79
|
+
task 'bench:report' do
|
|
73
80
|
ruby 'benchmark/report.rb'
|
|
74
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
|
|
@@ -21,7 +21,9 @@ module MoneyAttribute
|
|
|
21
21
|
#
|
|
22
22
|
# Created by +money_attribute+ or +money_amount+ and stored in the class-level registry.
|
|
23
23
|
# Used by query helpers to resolve column names, build Money values, and generate SQL.
|
|
24
|
-
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
26
|
+
AttributeSpec = Struct.new(:name, :kind, :amount_column, :currency_column, :amount_type, keyword_init: true) do
|
|
25
27
|
# @return [Boolean] +true+ when the spec describes a two-column (amount + currency) attribute.
|
|
26
28
|
def composite? = kind == :composite
|
|
27
29
|
|
|
@@ -32,7 +34,7 @@ module MoneyAttribute
|
|
|
32
34
|
#
|
|
33
35
|
# @return [Array<String>] two-element array for composite, one-element for single.
|
|
34
36
|
def columns
|
|
35
|
-
@columns ||= (composite? ? [
|
|
37
|
+
@columns ||= (composite? ? [amount_column, currency_column] : [amount_column]).freeze
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# @return [Boolean] +true+ when the amount column stores subunits (bigint).
|
|
@@ -42,7 +44,9 @@ module MoneyAttribute
|
|
|
42
44
|
def amount_extractor = integer_amount? ? :subunits : :to_d
|
|
43
45
|
|
|
44
46
|
# @return [Hash{String => Symbol}] mapping suitable for +composed_of+.
|
|
45
|
-
def composed_of_mapping
|
|
47
|
+
def composed_of_mapping
|
|
48
|
+
@composed_of_mapping ||= { amount_column => amount_extractor, currency_column => :currency_code }.freeze
|
|
49
|
+
end
|
|
46
50
|
|
|
47
51
|
# @return [Proc] the constructor lambda used by +composed_of+ to instantiate Money values.
|
|
48
52
|
def constructor
|
|
@@ -54,6 +58,7 @@ module MoneyAttribute
|
|
|
54
58
|
# @param amount [Integer, BigDecimal, nil] the raw column value
|
|
55
59
|
# @param currency [String, Mint::Currency, nil] the currency code or object
|
|
56
60
|
# @return [Mint::Money, nil] the resolved money value, or nil when amount is nil
|
|
61
|
+
# @api private
|
|
57
62
|
def build_money(amount, currency)
|
|
58
63
|
return unless amount
|
|
59
64
|
return amount if amount.is_a?(Mint::Money)
|
|
@@ -68,6 +73,7 @@ module MoneyAttribute
|
|
|
68
73
|
#
|
|
69
74
|
# @param value [Mint::Money, Numeric] the query value
|
|
70
75
|
# @return [Integer, BigDecimal, Mint::Money] the normalized value
|
|
76
|
+
# @api private
|
|
71
77
|
def normalize_query_value(value)
|
|
72
78
|
return value unless integer_amount?
|
|
73
79
|
return value.subunits if value.is_a?(Mint::Money)
|
|
@@ -4,26 +4,33 @@ require 'concurrent/map'
|
|
|
4
4
|
|
|
5
5
|
module MoneyAttribute
|
|
6
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
|
|
7
12
|
module AttributeSpecRegistry
|
|
8
13
|
extend ActiveSupport::Concern
|
|
9
14
|
|
|
10
15
|
REGISTRY = Concurrent::Map.new
|
|
16
|
+
PATTERNS = Concurrent::Map.new
|
|
11
17
|
|
|
12
18
|
class_methods do
|
|
13
19
|
# Registers a money attribute spec for the current model class.
|
|
14
20
|
#
|
|
15
21
|
# @param name [Symbol, String] the attribute name
|
|
16
22
|
# @param kind [Symbol] +:composite+ or +:single+
|
|
17
|
-
# @param
|
|
18
|
-
# @param
|
|
23
|
+
# @param amount_column [Symbol, String] the amount column name
|
|
24
|
+
# @param currency_column [Symbol, String, nil] the currency column name (composite only)
|
|
19
25
|
# @param amount_type [Symbol, nil] +:integer+ or +:decimal+
|
|
20
26
|
# @return [AttributeSpec]
|
|
21
|
-
|
|
27
|
+
# @api private
|
|
28
|
+
def register_money_attribute_spec(name, kind:, amount_column:, currency_column: nil, amount_type: nil)
|
|
22
29
|
spec = MoneyAttribute::AttributeSpec.new(
|
|
23
30
|
name: name.to_s,
|
|
24
31
|
kind: kind,
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
amount_column: amount_column.to_s,
|
|
33
|
+
currency_column: currency_column&.to_s,
|
|
27
34
|
amount_type: amount_type
|
|
28
35
|
)
|
|
29
36
|
|
|
@@ -35,6 +42,7 @@ module MoneyAttribute
|
|
|
35
42
|
#
|
|
36
43
|
# @param name [Symbol, String] the attribute name
|
|
37
44
|
# @return [AttributeSpec, nil]
|
|
45
|
+
# @api private
|
|
38
46
|
def money_attribute_spec(name)
|
|
39
47
|
REGISTRY[self]&.fetch(name.to_s, nil)
|
|
40
48
|
end
|
|
@@ -42,9 +50,29 @@ module MoneyAttribute
|
|
|
42
50
|
# Returns the registry hash for the current model class.
|
|
43
51
|
#
|
|
44
52
|
# @return [Hash{String => AttributeSpec}]
|
|
53
|
+
# @api private
|
|
45
54
|
def money_attribute_specs
|
|
46
55
|
REGISTRY.fetch_or_store(self) { {} }
|
|
47
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
|
|
48
76
|
end
|
|
49
77
|
end
|
|
50
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
|
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
|
-
#
|
|
4
|
+
# Converts raw attribute input into +Mint::Money+ values.
|
|
5
|
+
#
|
|
6
|
+
# Used in two roles: as the +:converter+ option of +composed_of+ (composite
|
|
7
|
+
# attributes) and as the normalizer block for +money_amount+ (single-column
|
|
8
|
+
# attributes). Accepts +Mint::Money+, numeric, string, and +nil+ input.
|
|
5
9
|
class Converter
|
|
6
10
|
DEFAULT = new.freeze
|
|
7
11
|
|
|
8
12
|
# Initializes a converter with an optional fixed currency.
|
|
13
|
+
#
|
|
14
|
+
# @param currency [String, Symbol, Mint::Currency, nil] the currency to use
|
|
15
|
+
# for parsed values; falls back to {MoneyAttribute.default_currency} when nil
|
|
16
|
+
# @return [Converter]
|
|
9
17
|
def initialize(currency = nil)
|
|
10
18
|
@static_currency = currency
|
|
11
19
|
end
|
|
12
20
|
|
|
13
21
|
# Returns the shared default converter instance.
|
|
22
|
+
#
|
|
23
|
+
# @return [Converter] the frozen, process-wide converter
|
|
14
24
|
def self.default
|
|
15
25
|
DEFAULT
|
|
16
26
|
end
|
|
17
27
|
|
|
18
|
-
# Converts raw input into a
|
|
28
|
+
# Converts raw input into a +Mint::Money+ value.
|
|
29
|
+
#
|
|
30
|
+
# @param amount [Mint::Money, Numeric, String, nil] the input value
|
|
31
|
+
# @return [Mint::Money, nil] +Mint::Money+ for numeric and string input,
|
|
32
|
+
# the input itself for +Mint::Money+ and +nil+
|
|
33
|
+
# @raise [ArgumentError] for unsupported input types
|
|
19
34
|
def parse(amount)
|
|
20
35
|
currency = @static_currency || MoneyAttribute.default_currency
|
|
21
36
|
case amount
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Convenience method for converting numeric values to +Mint::Money+.
|
|
4
|
+
#
|
|
5
|
+
# @api private
|
|
4
6
|
class Numeric
|
|
5
7
|
remove_method :to_money if method_defined?(:to_money)
|
|
6
8
|
|
|
9
|
+
# Converts the numeric value to a +Mint::Money+ value.
|
|
10
|
+
#
|
|
11
|
+
# @param currency [String, Symbol, Mint::Currency, nil] the currency to use;
|
|
12
|
+
# falls back to {MoneyAttribute.default_currency}
|
|
13
|
+
# @return [Mint::Money]
|
|
14
|
+
# @example
|
|
15
|
+
# 42.5.to_money('USD') # => Mint::Money(42.5, 'USD')
|
|
7
16
|
def to_money(currency = MoneyAttribute.default_currency) = Money.from(self, currency)
|
|
8
17
|
end
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Convenience method for parsing +Mint::Money+ from strings.
|
|
4
|
+
#
|
|
5
|
+
# @api private
|
|
4
6
|
class String
|
|
5
7
|
remove_method :to_money if method_defined?(:to_money)
|
|
6
8
|
|
|
9
|
+
# Parses the string into a +Mint::Money+ value.
|
|
10
|
+
#
|
|
11
|
+
# @param currency [String, Symbol, Mint::Currency, nil] the currency to use;
|
|
12
|
+
# falls back to {MoneyAttribute.default_currency}
|
|
13
|
+
# @return [Mint::Money]
|
|
14
|
+
# @raise [ArgumentError] if the string cannot be parsed
|
|
15
|
+
# @example
|
|
16
|
+
# '12.34'.to_money('USD') # => Mint::Money(12.34, 'USD')
|
|
7
17
|
def to_money(currency = MoneyAttribute.default_currency) = Money.parse(self, currency)
|
|
8
18
|
end
|
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
|
-
#
|
|
4
|
+
# Form builder methods for money attributes.
|
|
5
|
+
#
|
|
6
|
+
# Included into +ActionView::Helpers::FormBuilder+ by the railtie,
|
|
7
|
+
# providing two helper methods that mirror Rails' +text_field+ and
|
|
8
|
+
# +number_field+ but work with +Mint::Money+ attribute values.
|
|
9
|
+
#
|
|
10
|
+
# Both helpers render unbound <tt><input></tt> tags (not scoped to
|
|
11
|
+
# the form builder's object name), so the submitted value is accessible
|
|
12
|
+
# via +params+ directly rather than through +params[object_name]+.
|
|
13
|
+
#
|
|
14
|
+
# @example In a view
|
|
15
|
+
# <%= form_with model: @product do |f| %>
|
|
16
|
+
# <%= f.money_field :price %>
|
|
17
|
+
# <%= f.money_amount_field :discount %>
|
|
18
|
+
# <% end %>
|
|
5
19
|
module FormBuilderExtension
|
|
6
|
-
# Renders a text input for a composed money attribute.
|
|
20
|
+
# Renders a text input for a composed (amount + currency) money attribute.
|
|
21
|
+
#
|
|
22
|
+
# Displays the formatted money string (e.g. +"R$ 1.234,56"+) via
|
|
23
|
+
# {Mint::Money#to_fs}. The raw value is submitted as a string; the
|
|
24
|
+
# application should parse it on the receiving end, typically using
|
|
25
|
+
# {MoneyAttribute::Converter#parse}.
|
|
26
|
+
#
|
|
27
|
+
# @param method [Symbol] the money attribute accessor name
|
|
28
|
+
# @param options [Hash] HTML attributes passed through to the input tag
|
|
29
|
+
# @return [String] an HTML <tt><input type="text"></tt> tag
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# f.money_field :price
|
|
33
|
+
# # => <input type="text" id="product_price" name="product_price" value="R$ 1.234,56">
|
|
34
|
+
#
|
|
35
|
+
# @example With CSS class
|
|
36
|
+
# f.money_field :price, class: "form-control"
|
|
7
37
|
def money_field(method, options = {})
|
|
8
38
|
money = object.public_send(method)
|
|
9
39
|
value = money&.to_fs
|
|
@@ -12,7 +42,22 @@ module MoneyAttribute
|
|
|
12
42
|
{ id: field_id(method) }.merge(options))
|
|
13
43
|
end
|
|
14
44
|
|
|
15
|
-
# Renders a number input for a single-column money attribute.
|
|
45
|
+
# Renders a number input for a single-column (fixed-currency) money attribute.
|
|
46
|
+
#
|
|
47
|
+
# Displays the raw decimal value (e.g. +"1234.56"+) via
|
|
48
|
+
# {Mint::Money#to_d}. This is suitable for attributes backed by a single
|
|
49
|
+
# column where the currency is fixed per application config.
|
|
50
|
+
#
|
|
51
|
+
# @param method [Symbol] the money attribute accessor name
|
|
52
|
+
# @param options [Hash] HTML attributes passed through to the input tag
|
|
53
|
+
# @return [String] an HTML <tt><input type="number"></tt> tag
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# f.money_amount_field :discount
|
|
57
|
+
# # => <input type="number" id="product_discount" name="product_discount" value="1234.56">
|
|
58
|
+
#
|
|
59
|
+
# @example With step and min
|
|
60
|
+
# f.money_amount_field :discount, step: 0.01, min: 0
|
|
16
61
|
def money_amount_field(method, options = {})
|
|
17
62
|
money_from_column = object.public_send(method)
|
|
18
63
|
value = money_from_column&.to_d
|
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
|
-
#
|
|
4
|
+
# Declares composite money attributes on Active Record models.
|
|
5
|
+
#
|
|
6
|
+
# Provides the +money_attribute+ class method which wires a two-column
|
|
7
|
+
# (amount + currency) backing store to a +Mint::Money+ value object via
|
|
8
|
+
# +composed_of+.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class Product < ApplicationRecord
|
|
12
|
+
# money_attribute :price
|
|
13
|
+
# end
|
|
5
14
|
module Macro
|
|
6
15
|
extend ActiveSupport::Concern
|
|
7
16
|
|
|
8
|
-
#
|
|
17
|
+
# Class methods backing the +money_attribute+ macro.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
9
20
|
module CompositeClassMethods
|
|
21
|
+
include ColumnTypeValidations
|
|
22
|
+
|
|
10
23
|
# Normalizes the requested mapping by applying conventions and overrides.
|
|
24
|
+
#
|
|
25
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
26
|
+
# @param mapping_override [Hash] the user-supplied +mapping:+ option
|
|
27
|
+
# @return [Hash{Symbol => String}] resolved +:amount+ and +:currency+ column names
|
|
28
|
+
# @raise [ArgumentError] if the resolved columns do not exist on the model
|
|
29
|
+
# @api private
|
|
11
30
|
def resolve_mapping(name, mapping_override)
|
|
12
31
|
override = mapping_override.compact
|
|
13
32
|
override.slice!(:amount, :currency)
|
|
@@ -20,6 +39,13 @@ module MoneyAttribute
|
|
|
20
39
|
end
|
|
21
40
|
|
|
22
41
|
# Returns the default amount/currency mapping for the attribute name.
|
|
42
|
+
#
|
|
43
|
+
# Resolution order: +name_currency+ + +name+ columns, +amount+ + +currency+
|
|
44
|
+
# for +:amount+, then the +name_amount+ + +name_currency+ convention.
|
|
45
|
+
#
|
|
46
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
47
|
+
# @return [Hash{Symbol => String}] +:amount+ and +:currency+ column names
|
|
48
|
+
# @api private
|
|
23
49
|
def default_mapping(name)
|
|
24
50
|
name = name.to_s
|
|
25
51
|
names = column_names
|
|
@@ -34,18 +60,35 @@ module MoneyAttribute
|
|
|
34
60
|
end
|
|
35
61
|
|
|
36
62
|
# Registers the composite money attribute spec for the model.
|
|
63
|
+
#
|
|
64
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
65
|
+
# @param mapping [Hash{Symbol => String}] +:amount+ and +:currency+ column names
|
|
66
|
+
# @return [AttributeSpec] the registered spec
|
|
67
|
+
# @raise [ArgumentError] if either column has an unsupported type
|
|
68
|
+
# @api private
|
|
37
69
|
def register_composite_spec(name, mapping)
|
|
38
|
-
|
|
70
|
+
amount_column = column_for_attribute(mapping[:amount])
|
|
71
|
+
currency_column = column_for_attribute(mapping[:currency])
|
|
72
|
+
|
|
73
|
+
assert_valid_amount_column!(name, mapping[:amount], amount_column)
|
|
74
|
+
assert_valid_currency_column!(name, mapping[:currency], currency_column)
|
|
75
|
+
|
|
39
76
|
register_money_attribute_spec(
|
|
40
77
|
name,
|
|
41
78
|
kind: :composite,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
amount_type: %i[integer bigint].include?(
|
|
79
|
+
amount_column: mapping[:amount],
|
|
80
|
+
currency_column: mapping[:currency],
|
|
81
|
+
amount_type: %i[integer bigint].include?(amount_column.type) ? :integer : :decimal
|
|
45
82
|
)
|
|
46
83
|
end
|
|
47
84
|
|
|
48
85
|
# Raises when the resolved columns are not present on the model.
|
|
86
|
+
#
|
|
87
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
88
|
+
# @param mapping [Hash{Symbol => String}] +:amount+ and +:currency+ column names
|
|
89
|
+
# @return [void]
|
|
90
|
+
# @raise [ArgumentError] listing expected vs found columns
|
|
91
|
+
# @api private
|
|
49
92
|
def assert_columns_exist!(name, mapping)
|
|
50
93
|
missing = mapping.values - column_names
|
|
51
94
|
return if missing.empty?
|
|
@@ -59,6 +102,20 @@ module MoneyAttribute
|
|
|
59
102
|
|
|
60
103
|
class_methods do
|
|
61
104
|
# Declares a composite money attribute on the model.
|
|
105
|
+
#
|
|
106
|
+
# Stores the attribute across two columns (amount + currency). The amount
|
|
107
|
+
# column type determines the storage unit: integer/bigint stores subunits,
|
|
108
|
+
# decimal stores the unit value. Currency is resolved per row.
|
|
109
|
+
#
|
|
110
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
111
|
+
# @param mapping [Hash] custom column mapping (+:amount+, +:currency+)
|
|
112
|
+
# @return [void]
|
|
113
|
+
#
|
|
114
|
+
# @example
|
|
115
|
+
# class Product < ApplicationRecord
|
|
116
|
+
# money_attribute :price
|
|
117
|
+
# money_attribute :price, mapping: { amount: :base_price, currency: :base_currency }
|
|
118
|
+
# end
|
|
62
119
|
def money_attribute(name, mapping: {})
|
|
63
120
|
mapping = resolve_mapping(name, mapping)
|
|
64
121
|
spec = register_composite_spec(name, mapping)
|