money_attribute 0.14.5 → 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.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # :nodoc:
5
+ module MoneyAmount
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ # Declares a fixed-currency money attribute backed by a single column.
10
+ def money_amount(name)
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)
30
+ end
31
+ end
32
+ end
33
+ 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,25 +20,41 @@ 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
- ::Mint.locale_backend = lambda {
25
- fmt = I18n.t('number.currency.format', default: {})
26
- translator = ->(s) { s&.gsub('%n', '%<amount>f')&.gsub('%u', '%<symbol>s') }
27
-
28
- format = if fmt.key?(:positive) || fmt.key?(:negative) || fmt.key?(:zero)
29
- {
30
- positive: translator.call(fmt[:positive] || fmt[:format]),
31
- negative: translator.call(fmt[:negative] || fmt[:format]),
32
- zero: translator.call(fmt[:zero] || fmt[:format])
33
- }
34
- else
35
- translator.call(fmt[:format])
36
- end
37
-
38
- { decimal: fmt[:separator], thousand: fmt[:delimiter], format: format }
25
+ ::Mint.locale_backend = method(:build_locale_format).to_proc
26
+ end
27
+
28
+ # Builds the locale-aware currency formatting hash.
29
+ def self.build_locale_format
30
+ fmt = I18n.t('number.currency.format', default: {})
31
+ { decimal: fmt[:separator], thousand: fmt[:delimiter], format: build_format(fmt) }
32
+ end
33
+
34
+ # Builds the final currency format string or hash for Mint.
35
+ def self.build_format(fmt)
36
+ if %i[positive negative zero].any? { |k| fmt.key?(k) }
37
+ build_hash_format(fmt)
38
+ else
39
+ translate_format(fmt[:format])
40
+ end
41
+ end
42
+
43
+ # Builds a per-sign currency format hash.
44
+ def self.build_hash_format(fmt)
45
+ {
46
+ positive: translate_format(fmt[:positive] || fmt[:format]),
47
+ negative: translate_format(fmt[:negative] || fmt[:format]),
48
+ zero: translate_format(fmt[:zero] || fmt[:format])
39
49
  }
40
50
  end
41
51
 
52
+ # Translates Rails currency placeholders into Mint placeholders.
53
+ def self.translate_format(str)
54
+ str.to_s.gsub('%n', '%<amount>f').gsub('%u', '%<symbol>s')
55
+ end
56
+
57
+ # Registers custom currencies configured by the application.
42
58
  def self.register_custom_currencies!
43
59
  Array(MoneyAttribute.config.added_currencies).each do |currency_data|
44
60
  if currency_data.respond_to?(:values_at)
@@ -48,7 +64,7 @@ module MoneyAttribute
48
64
  else
49
65
  code, subunit, symbol = *currency_data
50
66
  end
51
- ::Mint::Currency.register(code:, subunit:, symbol:)
67
+ Money::Currency.register(code:, subunit:, symbol:)
52
68
  rescue KeyError => e
53
69
  unless e.message.include?('already registered')
54
70
  raise ArgumentError,
@@ -1,60 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
- # Type
5
- class Type < ActiveRecord::Type::Value
6
- def initialize(currency:, column_type: ActiveRecord::Type::Decimal.new)
7
- @currency = currency
8
- @column_type = column_type
9
- super()
10
- end
11
-
12
- def cast(value)
13
- case value
14
- when String then Mint::Money.parse(value, @currency)
15
- else super
16
- end
17
- end
4
+ # Base type for money amount attributes. Handles casting and validation.
5
+ class AmountType < ActiveRecord::Type::Value
6
+ # Casts string input into a +Mint::Money+ value.
7
+ #
8
+ # @param value [String, Numeric, Mint::Money, nil] the input value
9
+ # @return [Mint::Money, Numeric, nil] a Money value for strings, otherwise delegates to super
10
+ def cast(value) = value.is_a?(String) ? Money.parse(value, MoneyAttribute.default_currency) : super
18
11
 
12
+ # Validates that the value is compatible with the fixed currency type.
13
+ #
14
+ # @param value [Object] the value to validate
15
+ # @return [void]
16
+ # @raise [ArgumentError] when the value has a mismatched currency or is an unsupported type
19
17
  def assert_valid_value(value)
20
18
  case value
21
19
  when NilClass, Numeric, String then return
22
20
  when Mint::Money
23
- return if value.currency == @currency
21
+ currency = MoneyAttribute.default_currency
22
+ return if value.currency == currency
24
23
 
25
- message = "'#{value.inspect}' has different currency. Only #{@currency.code} allowed."
24
+ message = "'#{value.inspect}' has different currency. Only #{currency.code} allowed."
26
25
  else
27
26
  message = "'#{value.inspect}' is not a valid type for the attribute."
28
27
  end
29
28
  raise ArgumentError, message
30
29
  end
30
+ end
31
31
 
32
- def deserialize(value)
33
- return nil unless value
32
+ # Type for integer columns storing subunits (e.g. cents).
33
+ class IntegerAmountType < AmountType
34
+ # Deserializes a subunit integer into a +Mint::Money+ value.
35
+ #
36
+ # @param value [Integer, nil] the raw database value
37
+ # @return [Mint::Money, nil]
38
+ def deserialize(value) = value && Money.from_subunits(value.to_i, MoneyAttribute.default_currency)
34
39
 
35
- if @column_type.is_a?(ActiveRecord::Type::Integer)
36
- Mint::Money.from_subunits(value, @currency)
37
- else
38
- Mint::Money.from(value, @currency)
39
- end
40
- end
40
+ # Serializes a +Mint::Money+ value into subunits.
41
+ #
42
+ # @param value [Mint::Money, nil]
43
+ # @return [Integer, nil]
44
+ def serialize(value) = value&.subunits
45
+ end
41
46
 
42
- def serialize(value)
43
- return nil unless value
47
+ # Type for decimal columns storing unit values (e.g. 12.34).
48
+ class DecimalAmountType < AmountType
49
+ # Deserializes a decimal value into a +Mint::Money+ value.
50
+ #
51
+ # @param value [BigDecimal, nil] the raw database value
52
+ # @return [Mint::Money, nil]
53
+ def deserialize(value) = value && Money.from(value, MoneyAttribute.default_currency)
44
54
 
45
- if @column_type.is_a?(ActiveRecord::Type::Integer)
46
- value.subunits
47
- else
48
- value.to_d
49
- end
50
- end
51
-
52
- def self.type = :mint_money
55
+ # Serializes a +Mint::Money+ value into a decimal.
56
+ #
57
+ # @param value [Mint::Money, nil]
58
+ # @return [BigDecimal, nil]
59
+ def serialize(value) = value&.to_d
53
60
  end
54
61
  end
55
62
 
56
63
  ActiveSupport.on_load(:active_record) do
57
64
  include MoneyAttribute::Macro
58
-
59
- ActiveRecord::Type.register(:mint_money, MoneyAttribute::Type)
65
+ include MoneyAttribute::MoneyAmount
60
66
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
- VERSION = '0.14.5'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -1,11 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # MoneyAttribute provides ActiveRecord integrations for the Minting money gem.
4
+ #
5
+ # Two storage modes:
6
+ #
7
+ # - +money_attribute :price+ — composite (amount + currency columns)
8
+ # - +money_amount :price+ — single column (fixed currency)
9
+ #
10
+ # @see MoneyAttribute::Macro
11
+ # @see MoneyAttribute::MoneyAmount
12
+
3
13
  require 'minting'
4
- require 'money_attribute/core_ext'
14
+ require 'money_attribute/core_ext/numeric'
15
+ require 'money_attribute/core_ext/string'
5
16
  require 'money_attribute/configuration'
17
+ require 'money_attribute/current'
18
+ require 'money_attribute/attribute_spec'
19
+ require 'money_attribute/attribute_spec_registry'
6
20
  require 'money_attribute/macro'
21
+ require 'money_attribute/money_amount'
7
22
  require 'money_attribute/converter'
8
23
  require 'money_attribute/type'
9
24
  require 'money_attribute/form_builder_extension'
25
+ require 'money_attribute/query'
10
26
  require 'money_attribute/railtie'
11
27
  require 'money_attribute/version'