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
|
@@ -7,95 +7,72 @@ module MoneyAttribute
|
|
|
7
7
|
|
|
8
8
|
# :nodoc:
|
|
9
9
|
module CompositeClassMethods
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
# Normalizes the requested mapping by applying conventions and overrides.
|
|
11
|
+
def resolve_mapping(name, mapping_override)
|
|
12
|
+
override = mapping_override.compact
|
|
13
|
+
override.slice!(:amount, :currency)
|
|
14
|
+
override.transform_values!(&:to_s)
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
mapping = default_mapping(name).merge(override)
|
|
17
|
+
|
|
18
|
+
assert_columns_exist!(name, mapping)
|
|
19
|
+
mapping
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
# Returns the default amount/currency mapping for the attribute name.
|
|
23
|
+
def default_mapping(name)
|
|
24
|
+
name = name.to_s
|
|
25
|
+
names = column_names
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
if names.include?("#{name}_currency") && names.include?(name)
|
|
28
|
+
{ amount: name, currency: "#{name}_currency" }
|
|
29
|
+
elsif name == 'amount' && names.include?('currency')
|
|
30
|
+
{ amount: name, currency: 'currency' }
|
|
31
|
+
else
|
|
32
|
+
{ amount: "#{name}_amount", currency: "#{name}_currency" }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
# Registers the composite money attribute spec for the model.
|
|
37
|
+
def register_composite_spec(name, mapping)
|
|
38
|
+
column = column_for_attribute(mapping[:amount])
|
|
39
|
+
register_money_attribute_spec(
|
|
40
|
+
name,
|
|
41
|
+
kind: :composite,
|
|
42
|
+
amount_col: mapping[:amount],
|
|
43
|
+
currency_col: mapping[:currency],
|
|
44
|
+
amount_type: %i[integer bigint].include?(column.type) ? :integer : :decimal
|
|
45
|
+
)
|
|
29
46
|
end
|
|
30
47
|
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
# Raises when the resolved columns are not present on the model.
|
|
49
|
+
def assert_columns_exist!(name, mapping)
|
|
50
|
+
missing = mapping.values - column_names
|
|
33
51
|
return if missing.empty?
|
|
34
52
|
|
|
35
53
|
raise ArgumentError,
|
|
36
54
|
"Could not find columns for :#{name} money attribute. " \
|
|
37
|
-
"Expected: #{
|
|
55
|
+
"Expected: #{mapping.values.join(', ')}, " \
|
|
38
56
|
"Found: #{attribute_names.join(', ')}"
|
|
39
57
|
end
|
|
58
|
+
end
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
build_money_constructor(:from_subunits, default)
|
|
47
|
-
else
|
|
48
|
-
build_money_constructor(:from, default)
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
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)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def integer_column?(column_name)
|
|
62
|
-
col = columns.find { |c| c.name == column_name }
|
|
63
|
-
%i[integer bigint].include?(col&.type)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def define_composite_money_attribute(name, mapping, currency)
|
|
67
|
-
aggregated = resolve_composite_for(name, mapping:)
|
|
60
|
+
class_methods do
|
|
61
|
+
# Declares a composite money attribute on the model.
|
|
62
|
+
def money_attribute(name, mapping: {})
|
|
63
|
+
mapping = resolve_mapping(name, mapping)
|
|
64
|
+
spec = register_composite_spec(name, mapping)
|
|
68
65
|
|
|
69
|
-
composed_of(name
|
|
66
|
+
composed_of(name, {
|
|
70
67
|
allow_nil: true,
|
|
71
68
|
class_name: 'Mint::Money',
|
|
72
|
-
constructor:
|
|
73
|
-
converter: Converter.
|
|
74
|
-
mapping:
|
|
75
|
-
aggregated[:amount] => amount_extractor_for(aggregated[:amount]),
|
|
76
|
-
aggregated[:currency] => :currency_code
|
|
77
|
-
}
|
|
69
|
+
constructor: spec.constructor,
|
|
70
|
+
converter: Converter.default,
|
|
71
|
+
mapping: spec.composed_of_mapping
|
|
78
72
|
})
|
|
79
73
|
end
|
|
80
74
|
end
|
|
81
75
|
|
|
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
76
|
included do
|
|
100
77
|
extend CompositeClassMethods
|
|
101
78
|
end
|
|
@@ -28,8 +28,7 @@ module MoneyAttribute
|
|
|
28
28
|
|
|
29
29
|
config = AMOUNT_CONFIG[options[:type] || :fiat_decimal]
|
|
30
30
|
unless config
|
|
31
|
-
raise ArgumentError,
|
|
32
|
-
"Invalid money amount type #{options[:type]}. Use :crypto_decimal, :fiat_decimal or :fiat_integer"
|
|
31
|
+
raise ArgumentError, "Invalid type #{options[:type]}. Use :crypto_decimal, :fiat_decimal or :fiat_integer"
|
|
33
32
|
end
|
|
34
33
|
|
|
35
34
|
options = { null: options[:null], default: options[:default] }.compact
|
|
@@ -6,31 +6,27 @@ module MoneyAttribute
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
class_methods do
|
|
9
|
+
# Declares a fixed-currency money attribute backed by a single column.
|
|
9
10
|
def money_amount(name)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
11
|
+
column = column_for_attribute(name)
|
|
12
|
+
|
|
13
|
+
unless column
|
|
14
|
+
raise ArgumentError,
|
|
15
|
+
"Column '#{name}' does not exist on this table. " \
|
|
16
|
+
"Add a column named '#{name}' or use a different accessor name."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if %i[integer bigint].include?(column.type)
|
|
20
|
+
amount_type = :integer
|
|
21
|
+
type_class = IntegerAmountType
|
|
22
|
+
else
|
|
23
|
+
amount_type = :decimal
|
|
24
|
+
type_class = DecimalAmountType
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attribute(name, type_class.new)
|
|
28
|
+
normalizes(name, with: Converter.default)
|
|
29
|
+
register_money_attribute_spec(name, kind: :single, amount_col: name, amount_type: amount_type)
|
|
34
30
|
end
|
|
35
31
|
end
|
|
36
32
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module AmountCondition
|
|
6
|
+
# Builds an amount filter for the registered money attribute.
|
|
7
|
+
#
|
|
8
|
+
# @param attr [Symbol] the money attribute name
|
|
9
|
+
# @param value [Mint::Money, Numeric, Range, Array] the filter value
|
|
10
|
+
# @return [ActiveRecord::Relation]
|
|
11
|
+
# @raise [ArgumentError] if the attribute is not a registered money attribute
|
|
12
|
+
def resolve_amount_condition(attr, value)
|
|
13
|
+
spec = money_attribute_spec!(attr)
|
|
14
|
+
col = arel_table[spec.amount_col]
|
|
15
|
+
|
|
16
|
+
where(build_amount_predicate(col, spec, value))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# Builds an Arel predicate for the given amount value.
|
|
22
|
+
def build_amount_predicate(col, spec, value)
|
|
23
|
+
case value
|
|
24
|
+
when Range
|
|
25
|
+
low = normalize_amount_value(spec, value.begin)
|
|
26
|
+
high = normalize_amount_value(spec, value.end)
|
|
27
|
+
pred = col.gteq(low)
|
|
28
|
+
value.exclude_end? ? pred.and(col.lt(high)) : pred.and(col.lteq(high))
|
|
29
|
+
when Array
|
|
30
|
+
col.in(value.map { |v| normalize_amount_value(spec, v) })
|
|
31
|
+
else
|
|
32
|
+
col.eq(normalize_amount_value(spec, value))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Normalizes a scalar value for Arel comparison.
|
|
37
|
+
#
|
|
38
|
+
# Composite attributes: the amount column is a plain column with no custom Type,
|
|
39
|
+
# so we must pre-normalize Money to the raw storage value (subunits or decimal).
|
|
40
|
+
# Single-column attributes: the column has a registered Type that handles
|
|
41
|
+
# serialization, so we pass Money objects through directly to avoid double conversion.
|
|
42
|
+
def normalize_amount_value(spec, value)
|
|
43
|
+
return value unless spec.composite?
|
|
44
|
+
|
|
45
|
+
spec.normalize_query_value(value)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module AmountOrder
|
|
6
|
+
# Builds an amount ordering for the registered money attribute.
|
|
7
|
+
#
|
|
8
|
+
# @param attr [Symbol] the money attribute name
|
|
9
|
+
# @param direction [Symbol] +:asc+ or +:desc+
|
|
10
|
+
# @return [ActiveRecord::Relation]
|
|
11
|
+
# @raise [ArgumentError] if the attribute is not a registered money attribute
|
|
12
|
+
def resolve_amount_order(attr, direction)
|
|
13
|
+
spec = money_attribute_spec!(attr)
|
|
14
|
+
|
|
15
|
+
if spec.composite?
|
|
16
|
+
order(spec.currency_col => :asc, spec.amount_col => direction)
|
|
17
|
+
else
|
|
18
|
+
order(spec.amount_col => direction)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module CurrencyCondition
|
|
6
|
+
# Builds a currency filter for the registered money attribute.
|
|
7
|
+
#
|
|
8
|
+
# @param attr [Symbol] the money attribute name
|
|
9
|
+
# @param currency [String, Mint::Currency] the currency code or object
|
|
10
|
+
# @return [ActiveRecord::Relation]
|
|
11
|
+
# @raise [ArgumentError] if the attribute is not a composite money attribute
|
|
12
|
+
def resolve_currency_condition(attr, currency)
|
|
13
|
+
spec = money_attribute_spec!(attr)
|
|
14
|
+
|
|
15
|
+
unless spec.composite?
|
|
16
|
+
raise ArgumentError, "#{klass.name}.#{attr} is a money_amount attribute with no currency column"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
code = currency.is_a?(Mint::Currency) ? currency.code : currency.to_s
|
|
20
|
+
where(spec.currency_col => code)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module QueryHelpers
|
|
6
|
+
# Returns the registered money attribute spec or raises when missing.
|
|
7
|
+
#
|
|
8
|
+
# @param attr [Symbol, String] the money attribute name
|
|
9
|
+
# @return [AttributeSpec]
|
|
10
|
+
# @raise [ArgumentError] if the attribute is not registered
|
|
11
|
+
def money_attribute_spec!(attr)
|
|
12
|
+
spec = klass.money_attribute_spec(attr)
|
|
13
|
+
raise ArgumentError, "#{attr} is not a money attribute on #{klass.name}" unless spec
|
|
14
|
+
|
|
15
|
+
spec
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Extracts a single value from a flat row at the given cursor position.
|
|
19
|
+
#
|
|
20
|
+
# @param row [Array] the flat row from +pluck+ or +pick+
|
|
21
|
+
# @param spec [AttributeSpec] the money attribute spec
|
|
22
|
+
# @param cursor [Integer] current position in the row array
|
|
23
|
+
# @return [Array(Object, Integer)] the extracted value and updated cursor
|
|
24
|
+
def extract_pick_value(row, spec, cursor)
|
|
25
|
+
return [row[cursor], cursor + 1] if spec.single?
|
|
26
|
+
|
|
27
|
+
[spec.build_money(row[cursor], row[cursor + 1]), cursor + 2]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module PickAmount
|
|
6
|
+
# Picks money-aware amounts for one or more attributes.
|
|
7
|
+
#
|
|
8
|
+
# @param attrs [Array<Symbol>] one or more registered money attribute names
|
|
9
|
+
# @return [Mint::Money, Array, nil] Money for a single attribute, row array for multiple, nil if empty
|
|
10
|
+
# @raise [ArgumentError] if any attribute is not a registered money attribute
|
|
11
|
+
def pick_amount(*attrs)
|
|
12
|
+
raise ArgumentError, 'No attribute specified' if attrs.empty?
|
|
13
|
+
|
|
14
|
+
specs = attrs.map { |attr| money_attribute_spec!(attr) }
|
|
15
|
+
return pick_single_amount(specs.first) if specs.length == 1
|
|
16
|
+
|
|
17
|
+
raw = pick(*specs.flat_map(&:columns))
|
|
18
|
+
return unless raw
|
|
19
|
+
|
|
20
|
+
cursor = 0
|
|
21
|
+
specs.map do |spec|
|
|
22
|
+
value, cursor = extract_pick_value(raw, spec, cursor)
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Picks a single money-aware attribute and returns a single value.
|
|
30
|
+
def pick_single_amount(spec)
|
|
31
|
+
raw = pick(*spec.columns)
|
|
32
|
+
return unless raw
|
|
33
|
+
return raw if spec.single?
|
|
34
|
+
|
|
35
|
+
spec.build_money(raw[0], raw[1])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module PluckAmount
|
|
6
|
+
# Plucks money-aware amounts for one or more attributes.
|
|
7
|
+
#
|
|
8
|
+
# @param attrs [Array<Symbol>] one or more registered money attribute names
|
|
9
|
+
# @return [Array<Mint::Money>] for a single attribute
|
|
10
|
+
# @return [Array<Array>] for multiple attributes, one row array per attribute
|
|
11
|
+
# @raise [ArgumentError] if any attribute is not a registered money attribute
|
|
12
|
+
def pluck_amount(*attrs)
|
|
13
|
+
raise ArgumentError, 'No attribute specified' if attrs.empty?
|
|
14
|
+
|
|
15
|
+
specs = attrs.map { |attr| money_attribute_spec!(attr) }
|
|
16
|
+
return pluck_single_amount(specs.first) if specs.length == 1
|
|
17
|
+
|
|
18
|
+
raw = pluck(*specs.flat_map(&:columns))
|
|
19
|
+
raw.map { |row| extract_money_row(row, specs) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Plucks a single money-aware attribute and returns money values.
|
|
25
|
+
def pluck_single_amount(spec)
|
|
26
|
+
return pluck(spec.amount_col) if spec.single?
|
|
27
|
+
|
|
28
|
+
pluck(spec.amount_col, spec.currency_col).map do |amount, currency|
|
|
29
|
+
spec.build_money(amount, currency)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Rebuilds a result row for multi-attribute plucks.
|
|
34
|
+
def extract_money_row(row, specs)
|
|
35
|
+
cursor = 0
|
|
36
|
+
|
|
37
|
+
specs.map do |spec|
|
|
38
|
+
value, cursor = extract_pick_value(row, spec, cursor)
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module SumAmount
|
|
6
|
+
# Sums money-aware amounts for a single attribute.
|
|
7
|
+
#
|
|
8
|
+
# @param attr [Symbol] a registered money attribute name
|
|
9
|
+
# @return [Array<Mint::Money>] one Money per currency (or one for single-column attributes)
|
|
10
|
+
# @raise [ArgumentError] if the attribute is not a registered money attribute
|
|
11
|
+
def sum_amount(attr)
|
|
12
|
+
raise ArgumentError, 'No attribute specified' if attr.nil?
|
|
13
|
+
|
|
14
|
+
spec = money_attribute_spec!(attr)
|
|
15
|
+
if spec.composite?
|
|
16
|
+
resolve_composite_sum(spec)
|
|
17
|
+
else
|
|
18
|
+
resolve_single_sum(spec)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Sums a composite attribute grouped by currency.
|
|
25
|
+
def resolve_composite_sum(spec)
|
|
26
|
+
totals = group(spec.currency_col).sum(spec.amount_col)
|
|
27
|
+
return [spec.build_money(0, MoneyAttribute.default_currency)] if totals.empty?
|
|
28
|
+
|
|
29
|
+
totals.map { |code, amount| spec.build_money(amount, code) }
|
|
30
|
+
.sort_by(&:currency_code)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Sums a fixed-currency single-column attribute.
|
|
34
|
+
def resolve_single_sum(spec)
|
|
35
|
+
total = sum(spec.amount_col)
|
|
36
|
+
|
|
37
|
+
[spec.build_money(total, MoneyAttribute.default_currency)]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'query/helpers'
|
|
4
|
+
require_relative 'query/currency_condition'
|
|
5
|
+
require_relative 'query/amount_condition'
|
|
6
|
+
require_relative 'query/amount_order'
|
|
7
|
+
require_relative 'query/pluck'
|
|
8
|
+
require_relative 'query/pick'
|
|
9
|
+
require_relative 'query/sum'
|
|
10
|
+
|
|
11
|
+
module MoneyAttribute
|
|
12
|
+
# Money-aware query helpers for ActiveRecord.
|
|
13
|
+
#
|
|
14
|
+
# Offer.where_currency(price: 'EUR')
|
|
15
|
+
# Offer.where_amount(price: 10..100)
|
|
16
|
+
# Offer.order_by_amount(price: :desc)
|
|
17
|
+
# Offer.pluck_amount(:price)
|
|
18
|
+
# Offer.pick_amount(:price)
|
|
19
|
+
# Offer.sum_amount(:price)
|
|
20
|
+
#
|
|
21
|
+
module Query
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
class_methods do
|
|
25
|
+
# Filters by currency for one or more money attributes.
|
|
26
|
+
#
|
|
27
|
+
# @param conditions [Hash{Symbol => String}] attribute name to currency code
|
|
28
|
+
# @return [ActiveRecord::Relation]
|
|
29
|
+
# @raise [ArgumentError] if the attribute is not a composite money attribute
|
|
30
|
+
def where_currency(conditions)
|
|
31
|
+
scope = all
|
|
32
|
+
conditions.each { |attr, value| scope = scope.resolve_currency_condition(attr, value) }
|
|
33
|
+
scope
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Filters by amount for one or more money attributes.
|
|
37
|
+
#
|
|
38
|
+
# Accepts +Mint::Money+ objects, numeric values (decimal columns), Ranges, or Arrays.
|
|
39
|
+
#
|
|
40
|
+
# Offer.where_amount(price: 10.dollars..100.dollars)
|
|
41
|
+
# Offer.where_amount(total: [10, 20, 30])
|
|
42
|
+
#
|
|
43
|
+
# @param conditions [Hash{Symbol => Numeric, Range, Array}] attribute name to filter value
|
|
44
|
+
# @return [ActiveRecord::Relation]
|
|
45
|
+
# @raise [ArgumentError] if the attribute is not a registered money attribute
|
|
46
|
+
def where_amount(conditions)
|
|
47
|
+
scope = all
|
|
48
|
+
conditions.each { |attr, value| scope = scope.resolve_amount_condition(attr, value) }
|
|
49
|
+
scope
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Orders by amount for one or more money attributes.
|
|
53
|
+
#
|
|
54
|
+
# Composite attributes sort by currency ASC first, then amount.
|
|
55
|
+
#
|
|
56
|
+
# @param conditions [Hash{Symbol => Symbol}] attribute name to +:asc+ or +:desc+
|
|
57
|
+
# @return [ActiveRecord::Relation]
|
|
58
|
+
# @raise [ArgumentError] if the attribute is not a registered money attribute
|
|
59
|
+
def order_by_amount(conditions)
|
|
60
|
+
scope = all
|
|
61
|
+
conditions.each { |attr, dir| scope = scope.resolve_amount_order(attr, dir || :asc) }
|
|
62
|
+
scope
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Plucks money-aware amounts from the current relation.
|
|
66
|
+
#
|
|
67
|
+
# Offer.pluck_amount(:price)
|
|
68
|
+
# # => [Mint::Money(10.0, 'EUR'), Mint::Money(20.0, 'USD')]
|
|
69
|
+
#
|
|
70
|
+
# @param attrs [Array<Symbol>] one or more registered money attribute names
|
|
71
|
+
# @return [Array<Mint::Money>] for a single attribute
|
|
72
|
+
# @return [Array<Array>] for multiple attributes, one row array per attribute
|
|
73
|
+
# @raise [ArgumentError] if any attribute is not a registered money attribute
|
|
74
|
+
def pluck_amount(*attrs)
|
|
75
|
+
all.pluck_amount(*attrs)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Picks a single money-aware value from the current relation.
|
|
79
|
+
#
|
|
80
|
+
# Offer.pick_amount(:price)
|
|
81
|
+
# # => Mint::Money(10.0, 'EUR')
|
|
82
|
+
#
|
|
83
|
+
# @param attrs [Array<Symbol>] one or more registered money attribute names
|
|
84
|
+
# @return [Mint::Money, Array, nil] Money for a single attribute, row array for multiple, nil if empty
|
|
85
|
+
# @raise [ArgumentError] if any attribute is not a registered money attribute
|
|
86
|
+
def pick_amount(*attrs)
|
|
87
|
+
all.pick_amount(*attrs)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Sums money-aware amounts, grouping by currency for composite attributes.
|
|
91
|
+
#
|
|
92
|
+
# Offer.sum_amount(:price)
|
|
93
|
+
# # => [Mint::Money(30.0, 'EUR'), Mint::Money(50.0, 'USD')]
|
|
94
|
+
#
|
|
95
|
+
# @param attr [Symbol] a registered money attribute name
|
|
96
|
+
# @return [Array<Mint::Money>] one Money per currency (or one for single-column attributes)
|
|
97
|
+
# @raise [ArgumentError] if the attribute is not a registered money attribute
|
|
98
|
+
def sum_amount(attr)
|
|
99
|
+
all.sum_amount(attr)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# :nodoc:
|
|
105
|
+
module QueryMethods
|
|
106
|
+
include QueryHelpers
|
|
107
|
+
include CurrencyCondition
|
|
108
|
+
include AmountCondition
|
|
109
|
+
include AmountOrder
|
|
110
|
+
include PluckAmount
|
|
111
|
+
include PickAmount
|
|
112
|
+
include SumAmount
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
ActiveSupport.on_load(:active_record) do
|
|
117
|
+
include MoneyAttribute::AttributeSpecRegistry
|
|
118
|
+
include MoneyAttribute::Query
|
|
119
|
+
ActiveRecord::Relation.include(MoneyAttribute::QueryMethods)
|
|
120
|
+
end
|
|
@@ -20,15 +20,18 @@ module MoneyAttribute
|
|
|
20
20
|
register_custom_currencies!
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Configures Mint to use the Rails locale currency format.
|
|
23
24
|
def self.setup_locale_backend!
|
|
24
25
|
::Mint.locale_backend = method(:build_locale_format).to_proc
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
# Builds the locale-aware currency formatting hash.
|
|
27
29
|
def self.build_locale_format
|
|
28
30
|
fmt = I18n.t('number.currency.format', default: {})
|
|
29
31
|
{ decimal: fmt[:separator], thousand: fmt[:delimiter], format: build_format(fmt) }
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
# Builds the final currency format string or hash for Mint.
|
|
32
35
|
def self.build_format(fmt)
|
|
33
36
|
if %i[positive negative zero].any? { |k| fmt.key?(k) }
|
|
34
37
|
build_hash_format(fmt)
|
|
@@ -37,6 +40,7 @@ module MoneyAttribute
|
|
|
37
40
|
end
|
|
38
41
|
end
|
|
39
42
|
|
|
43
|
+
# Builds a per-sign currency format hash.
|
|
40
44
|
def self.build_hash_format(fmt)
|
|
41
45
|
{
|
|
42
46
|
positive: translate_format(fmt[:positive] || fmt[:format]),
|
|
@@ -45,10 +49,12 @@ module MoneyAttribute
|
|
|
45
49
|
}
|
|
46
50
|
end
|
|
47
51
|
|
|
52
|
+
# Translates Rails currency placeholders into Mint placeholders.
|
|
48
53
|
def self.translate_format(str)
|
|
49
54
|
str.to_s.gsub('%n', '%<amount>f').gsub('%u', '%<symbol>s')
|
|
50
55
|
end
|
|
51
56
|
|
|
57
|
+
# Registers custom currencies configured by the application.
|
|
52
58
|
def self.register_custom_currencies!
|
|
53
59
|
Array(MoneyAttribute.config.added_currencies).each do |currency_data|
|
|
54
60
|
if currency_data.respond_to?(:values_at)
|
|
@@ -58,7 +64,7 @@ module MoneyAttribute
|
|
|
58
64
|
else
|
|
59
65
|
code, subunit, symbol = *currency_data
|
|
60
66
|
end
|
|
61
|
-
::
|
|
67
|
+
Money::Currency.register(code:, subunit:, symbol:)
|
|
62
68
|
rescue KeyError => e
|
|
63
69
|
unless e.message.include?('already registered')
|
|
64
70
|
raise ArgumentError,
|