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.
@@ -7,95 +7,72 @@ module MoneyAttribute
7
7
 
8
8
  # :nodoc:
9
9
  module CompositeClassMethods
10
- def resolve_composite_mapping(name)
11
- columns = attribute_names
12
- if columns.include?("#{name}_currency")
13
- return { amount: name, currency: :"#{name}_currency" } if columns.include?(name)
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
- nil
16
- elsif name == 'amount' && columns.include?('currency')
17
- { amount: name, currency: :currency }
18
- end
16
+ mapping = default_mapping(name).merge(override)
17
+
18
+ assert_columns_exist!(name, mapping)
19
+ mapping
19
20
  end
20
21
 
21
- def resolve_composite_for(name, mapping:)
22
- composite = { amount: "#{name}_amount", currency: "#{name}_currency" }
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
- composite[:amount] = mapping[:amount].to_s if mapping&.key?(:amount)
25
- composite[:currency] = mapping[:currency].to_s if mapping&.key?(:currency)
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
- assert_columns_exist!(name, composite)
28
- composite
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
- def assert_columns_exist!(name, composite)
32
- missing = composite.values - attribute_names
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: #{composite.values.join(', ')}, " \
55
+ "Expected: #{mapping.values.join(', ')}, " \
38
56
  "Found: #{attribute_names.join(', ')}"
39
57
  end
58
+ end
40
59
 
41
- def amount_extractor_for(column_name) = integer_column?(column_name) ? :subunits : :to_d
42
-
43
- def money_constructor_for(amount_column)
44
- default = MoneyAttribute.default_currency
45
- if integer_column?(amount_column)
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.to_sym, {
66
+ composed_of(name, {
70
67
  allow_nil: true,
71
68
  class_name: 'Mint::Money',
72
- constructor: money_constructor_for(aggregated[:amount]),
73
- converter: Converter.new(currency),
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
- name = name.to_s
11
-
12
- assert_column_exists!(name)
13
-
14
- currency = ::Mint::Currency.resolve!(MoneyAttribute.default_currency)
15
- column_type = detect_column_type(name)
16
-
17
- attribute(name.to_sym, MoneyAttribute::Type.new(currency:, column_type:))
18
- normalizes(name.to_sym, with: Converter.new)
19
- end
20
-
21
- private
22
-
23
- def assert_column_exists!(name)
24
- return if attribute_names.include?(name)
25
-
26
- raise ArgumentError,
27
- "Column '#{name}' does not exist on this table. " \
28
- "Add a column named '#{name}' or use a different accessor name."
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
- ::Mint::Currency.register(code:, subunit:, symbol:)
67
+ Money::Currency.register(code:, subunit:, symbol:)
62
68
  rescue KeyError => e
63
69
  unless e.message.include?('already registered')
64
70
  raise ArgumentError,