money_attribute 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +143 -41
- data/Rakefile +43 -2
- data/lib/generators/money_attribute/initializer_generator.rb +10 -0
- data/lib/generators/templates/money_attribute.rb +20 -12
- data/lib/money_attribute/attribute_spec.rb +84 -0
- data/lib/money_attribute/attribute_spec_registry.rb +78 -0
- data/lib/money_attribute/column_type_validations.rb +47 -0
- data/lib/money_attribute/configuration.rb +43 -15
- data/lib/money_attribute/converter.rb +30 -5
- data/lib/money_attribute/core_ext/numeric.rb +17 -0
- data/lib/money_attribute/core_ext/string.rb +11 -1
- data/lib/money_attribute/current.rb +13 -0
- data/lib/money_attribute/form_builder_extension.rb +48 -1
- data/lib/money_attribute/macro.rb +106 -72
- data/lib/money_attribute/migration_extensions/helper.rb +62 -3
- 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 +49 -25
- data/lib/money_attribute/query/amount_condition.rb +207 -0
- data/lib/money_attribute/query/amount_order.rb +28 -0
- data/lib/money_attribute/query/currency_condition.rb +29 -0
- data/lib/money_attribute/query/helpers.rb +53 -0
- data/lib/money_attribute/query/pick.rb +41 -0
- data/lib/money_attribute/query/pluck.rb +39 -0
- data/lib/money_attribute/query/sum.rb +52 -0
- data/lib/money_attribute/query.rb +125 -0
- data/lib/money_attribute/railtie.rb +47 -1
- data/lib/money_attribute/type.rb +41 -33
- data/lib/money_attribute/version.rb +2 -1
- data/lib/money_attribute.rb +16 -1
- metadata +17 -5
- data/lib/money_attribute/core_ext.rb +0 -8
|
@@ -1,27 +1,55 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# @private
|
|
5
|
+
CONFIG_MUTEX = Mutex.new
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
class << self
|
|
8
|
+
# Returns the lazily initialized gem configuration.
|
|
9
|
+
#
|
|
10
|
+
# @return [Config]
|
|
11
|
+
def config
|
|
12
|
+
CONFIG_MUTEX.synchronize { @config ||= Config.new }
|
|
10
13
|
end
|
|
11
|
-
end
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
# Yields the current configuration object for mutation.
|
|
16
|
+
#
|
|
17
|
+
# @yield [config] the current configuration
|
|
18
|
+
# @return [void]
|
|
19
|
+
def configure = yield config
|
|
20
|
+
|
|
21
|
+
# Returns the current request or default currency as a resolved currency.
|
|
22
|
+
# Memoized per-thread — within a request, Current.currency is stable.
|
|
23
|
+
#
|
|
24
|
+
# @return [Mint::Currency]
|
|
25
|
+
def default_currency
|
|
26
|
+
code = MoneyAttribute::Current.currency.presence || config.default_currency
|
|
15
27
|
|
|
16
|
-
|
|
28
|
+
last_code, last_currency = Thread.current[:money_attribute_default_currency]
|
|
29
|
+
return last_currency if last_code == code
|
|
17
30
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
currency = Money::Currency.resolve!(code)
|
|
32
|
+
Thread.current[:money_attribute_default_currency] = [code, currency]
|
|
33
|
+
currency
|
|
34
|
+
end
|
|
22
35
|
end
|
|
23
36
|
|
|
24
|
-
|
|
25
|
-
|
|
37
|
+
# Gem configuration holding the default currency and registered custom currencies.
|
|
38
|
+
#
|
|
39
|
+
# MoneyAttribute.configure do |config|
|
|
40
|
+
# config.default_currency = 'BRL'
|
|
41
|
+
# end
|
|
42
|
+
class Config
|
|
43
|
+
# @return [String] ISO 4217 currency code used when no per-request or per-row currency is set.
|
|
44
|
+
attr_accessor :default_currency
|
|
45
|
+
|
|
46
|
+
# @return [Array<Hash>] custom currencies registered via +register_custom_currencies!+.
|
|
47
|
+
attr_accessor :added_currencies
|
|
48
|
+
|
|
49
|
+
# Initializes the default gem configuration values.
|
|
50
|
+
def initialize
|
|
51
|
+
@default_currency = 'USD'
|
|
52
|
+
@added_currencies = []
|
|
53
|
+
end
|
|
26
54
|
end
|
|
27
55
|
end
|
|
@@ -1,17 +1,42 @@
|
|
|
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
|
-
|
|
7
|
-
|
|
10
|
+
DEFAULT = new.freeze
|
|
11
|
+
|
|
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]
|
|
17
|
+
def initialize(currency = nil)
|
|
18
|
+
@static_currency = currency
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns the shared default converter instance.
|
|
22
|
+
#
|
|
23
|
+
# @return [Converter] the frozen, process-wide converter
|
|
24
|
+
def self.default
|
|
25
|
+
DEFAULT
|
|
8
26
|
end
|
|
9
27
|
|
|
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
|
|
10
34
|
def parse(amount)
|
|
35
|
+
currency = @static_currency || MoneyAttribute.default_currency
|
|
11
36
|
case amount
|
|
12
37
|
when Money, NilClass then amount
|
|
13
|
-
when Numeric then Money.from(amount,
|
|
14
|
-
when String then Money.parse(amount,
|
|
38
|
+
when Numeric then Money.from(amount, currency)
|
|
39
|
+
when String then Money.parse(amount, currency)
|
|
15
40
|
else raise ArgumentError, "Cannot convert #{amount.inspect} (#{amount.class}) to Money"
|
|
16
41
|
end
|
|
17
42
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Convenience method for converting numeric values to +Mint::Money+.
|
|
4
|
+
#
|
|
5
|
+
# @api private
|
|
6
|
+
class Numeric
|
|
7
|
+
remove_method :to_money if method_defined?(:to_money)
|
|
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')
|
|
16
|
+
def to_money(currency = MoneyAttribute.default_currency) = Money.from(self, currency)
|
|
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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/current_attributes'
|
|
4
|
+
|
|
5
|
+
module MoneyAttribute
|
|
6
|
+
# Per-request currency container. Set MoneyAttribute::Current.currency in your
|
|
7
|
+
# controller (or a before_action) to override the configured default for that request.
|
|
8
|
+
# Automatically reset after each request by MoneyAttribute::Middleware.
|
|
9
|
+
class Current < ::ActiveSupport::CurrentAttributes
|
|
10
|
+
# @return [String, nil] per-request ISO 4217 currency code override.
|
|
11
|
+
attribute :currency
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -1,8 +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
|
|
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"
|
|
6
37
|
def money_field(method, options = {})
|
|
7
38
|
money = object.public_send(method)
|
|
8
39
|
value = money&.to_fs
|
|
@@ -11,6 +42,22 @@ module MoneyAttribute
|
|
|
11
42
|
{ id: field_id(method) }.merge(options))
|
|
12
43
|
end
|
|
13
44
|
|
|
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
|
|
14
61
|
def money_amount_field(method, options = {})
|
|
15
62
|
money_from_column = object.public_send(method)
|
|
16
63
|
value = money_from_column&.to_d
|
|
@@ -1,101 +1,135 @@
|
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
include ColumnTypeValidations
|
|
22
|
+
|
|
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
|
|
30
|
+
def resolve_mapping(name, mapping_override)
|
|
31
|
+
override = mapping_override.compact
|
|
32
|
+
override.slice!(:amount, :currency)
|
|
33
|
+
override.transform_values!(&:to_s)
|
|
34
|
+
|
|
35
|
+
mapping = default_mapping(name).merge(override)
|
|
36
|
+
|
|
37
|
+
assert_columns_exist!(name, mapping)
|
|
38
|
+
mapping
|
|
19
39
|
end
|
|
20
40
|
|
|
21
|
-
|
|
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
|
|
49
|
+
def default_mapping(name)
|
|
50
|
+
name = name.to_s
|
|
51
|
+
names = column_names
|
|
23
52
|
|
|
24
|
-
|
|
25
|
-
|
|
53
|
+
if names.include?("#{name}_currency") && names.include?(name)
|
|
54
|
+
{ amount: name, currency: "#{name}_currency" }
|
|
55
|
+
elsif name == 'amount' && names.include?('currency')
|
|
56
|
+
{ amount: name, currency: 'currency' }
|
|
57
|
+
else
|
|
58
|
+
{ amount: "#{name}_amount", currency: "#{name}_currency" }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
26
61
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
69
|
+
def register_composite_spec(name, mapping)
|
|
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
|
+
|
|
76
|
+
register_money_attribute_spec(
|
|
77
|
+
name,
|
|
78
|
+
kind: :composite,
|
|
79
|
+
amount_column: mapping[:amount],
|
|
80
|
+
currency_column: mapping[:currency],
|
|
81
|
+
amount_type: %i[integer bigint].include?(amount_column.type) ? :integer : :decimal
|
|
82
|
+
)
|
|
29
83
|
end
|
|
30
84
|
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
92
|
+
def assert_columns_exist!(name, mapping)
|
|
93
|
+
missing = mapping.values - column_names
|
|
33
94
|
return if missing.empty?
|
|
34
95
|
|
|
35
96
|
raise ArgumentError,
|
|
36
97
|
"Could not find columns for :#{name} money attribute. " \
|
|
37
|
-
"Expected: #{
|
|
98
|
+
"Expected: #{mapping.values.join(', ')}, " \
|
|
38
99
|
"Found: #{attribute_names.join(', ')}"
|
|
39
100
|
end
|
|
101
|
+
end
|
|
40
102
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:)
|
|
68
|
-
|
|
69
|
-
composed_of(name.to_sym, {
|
|
103
|
+
class_methods do
|
|
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
|
|
119
|
+
def money_attribute(name, mapping: {})
|
|
120
|
+
mapping = resolve_mapping(name, mapping)
|
|
121
|
+
spec = register_composite_spec(name, mapping)
|
|
122
|
+
|
|
123
|
+
composed_of(name, {
|
|
70
124
|
allow_nil: true,
|
|
71
125
|
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
|
-
}
|
|
126
|
+
constructor: spec.constructor,
|
|
127
|
+
converter: Converter.default,
|
|
128
|
+
mapping: spec.composed_of_mapping
|
|
78
129
|
})
|
|
79
130
|
end
|
|
80
131
|
end
|
|
81
132
|
|
|
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
133
|
included do
|
|
100
134
|
extend CompositeClassMethods
|
|
101
135
|
end
|
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
4
|
module MigrationExtensions
|
|
5
|
-
#
|
|
5
|
+
# Shared argument-parsing logic for migration helpers.
|
|
6
|
+
#
|
|
7
|
+
# Resolves accessor names, column overrides, and amount type configuration
|
|
8
|
+
# into concrete column definitions. Included by both {SchemaStatements} and
|
|
9
|
+
# {TableDefinition}.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [rw] AMOUNT_CONFIG
|
|
12
|
+
# @return [Hash{Symbol => Hash}] mapping of symbolic type names to
|
|
13
|
+
# column type/hash pairs
|
|
14
|
+
# @!attribute [rw] CURRENCY_MIN_LIMIT
|
|
15
|
+
# @return [Integer] minimum allowed currency column string limit (8)
|
|
16
|
+
# @!attribute [rw] CURRENCY_DEFAULT_LIMIT
|
|
17
|
+
# @return [Integer] default currency column string limit (20)
|
|
6
18
|
module Helper
|
|
7
19
|
AMOUNT_CONFIG = {
|
|
8
20
|
crypto_decimal: { type: :decimal, precision: 36, scale: 18 },
|
|
@@ -15,6 +27,19 @@ module MoneyAttribute
|
|
|
15
27
|
|
|
16
28
|
private
|
|
17
29
|
|
|
30
|
+
# Parses arguments for a single-column (amount-only) migration.
|
|
31
|
+
#
|
|
32
|
+
# @param accessor [Symbol, String] the money attribute name
|
|
33
|
+
# @param options [Hash] column options
|
|
34
|
+
# @option options [Symbol] :column explicit column name override
|
|
35
|
+
# @option options [Symbol] :type amount type (+:fiat_decimal+,
|
|
36
|
+
# +:crypto_decimal+, +:fiat_integer+)
|
|
37
|
+
# @option options [Boolean] :null whether the column allows NULL
|
|
38
|
+
# @option options [Object] :default default value for the column
|
|
39
|
+
# @return [Array(String, Hash)] column name and merged options hash
|
|
40
|
+
# @raise [ArgumentError] if +precision:+ or +scale:+ are given, or
|
|
41
|
+
# type is unrecognized
|
|
42
|
+
# @api private
|
|
18
43
|
def parse_money_amount_args(accessor, options)
|
|
19
44
|
options ||= {}
|
|
20
45
|
if options.key?(:precision) || options.key?(:scale)
|
|
@@ -28,14 +53,24 @@ module MoneyAttribute
|
|
|
28
53
|
|
|
29
54
|
config = AMOUNT_CONFIG[options[:type] || :fiat_decimal]
|
|
30
55
|
unless config
|
|
31
|
-
raise ArgumentError,
|
|
32
|
-
"Invalid money amount type #{options[:type]}. Use :crypto_decimal, :fiat_decimal or :fiat_integer"
|
|
56
|
+
raise ArgumentError, "Invalid type #{options[:type]}. Use :crypto_decimal, :fiat_decimal or :fiat_integer"
|
|
33
57
|
end
|
|
34
58
|
|
|
35
59
|
options = { null: options[:null], default: options[:default] }.compact
|
|
36
60
|
[column, config.merge(options)]
|
|
37
61
|
end
|
|
38
62
|
|
|
63
|
+
# Parses arguments for the currency column in a composite migration.
|
|
64
|
+
#
|
|
65
|
+
# @param accessor [Symbol, String] the money attribute name
|
|
66
|
+
# @param options [Hash] currency column options
|
|
67
|
+
# @option options [Symbol] :column explicit currency column name override
|
|
68
|
+
# @option options [Integer] :limit string limit for the column
|
|
69
|
+
# @option options [Boolean] :null whether the column allows NULL
|
|
70
|
+
# @option options [Object] :default default value for the column
|
|
71
|
+
# @return [Array(String, Hash)] column name and merged options hash
|
|
72
|
+
# @raise [ArgumentError] if limit is below {CURRENCY_MIN_LIMIT}
|
|
73
|
+
# @api private
|
|
39
74
|
def parse_currency_args(accessor, options)
|
|
40
75
|
options ||= {}
|
|
41
76
|
limit = (options[:limit] || CURRENCY_DEFAULT_LIMIT).to_i
|
|
@@ -50,6 +85,18 @@ module MoneyAttribute
|
|
|
50
85
|
[column, { limit:, null: options[:null], default: options[:default] }.compact]
|
|
51
86
|
end
|
|
52
87
|
|
|
88
|
+
# Resolves the currency column name for the given accessor.
|
|
89
|
+
#
|
|
90
|
+
# Resolution order:
|
|
91
|
+
# 1. Explicit +column_override+ → returned as-is
|
|
92
|
+
# 2. Accessor is +:amount+ → +currency+
|
|
93
|
+
# 3. Accessor ends with +_amount+ → strips suffix and appends +_currency+
|
|
94
|
+
# 4. Otherwise → +<accessor>_currency+
|
|
95
|
+
#
|
|
96
|
+
# @param accessor [Symbol, String] the money attribute name
|
|
97
|
+
# @param column_override [Symbol, String, nil] explicit column name
|
|
98
|
+
# @return [String] the resolved currency column name
|
|
99
|
+
# @api private
|
|
53
100
|
def currency_column_name(accessor, column_override)
|
|
54
101
|
return column_override.to_s if column_override
|
|
55
102
|
|
|
@@ -60,6 +107,18 @@ module MoneyAttribute
|
|
|
60
107
|
"#{radical}_currency"
|
|
61
108
|
end
|
|
62
109
|
|
|
110
|
+
# Parses arguments for a composite (amount + currency) migration.
|
|
111
|
+
#
|
|
112
|
+
# Delegates to {#parse_money_amount_args} and {#parse_currency_args}
|
|
113
|
+
# using the nested +:amount+ and +:currency+ option keys.
|
|
114
|
+
#
|
|
115
|
+
# @param accessor [Symbol, String] the money attribute name
|
|
116
|
+
# @param options [Hash] migration options
|
|
117
|
+
# @option options [Hash] :amount amount column options
|
|
118
|
+
# @option options [Hash] :currency currency column options
|
|
119
|
+
# @return [Array(String, String, Hash, Hash)] amount column name,
|
|
120
|
+
# currency column name, amount options, currency options
|
|
121
|
+
# @api private
|
|
63
122
|
def parse_money_args(accessor, options = {})
|
|
64
123
|
amount_column, amount_options = parse_money_amount_args(accessor, options[:amount])
|
|
65
124
|
currency_column, currency_options = parse_currency_args(accessor, options[:currency])
|
|
@@ -4,32 +4,112 @@ require_relative 'helper'
|
|
|
4
4
|
|
|
5
5
|
module MoneyAttribute
|
|
6
6
|
module MigrationExtensions
|
|
7
|
-
#
|
|
7
|
+
# Migration helper methods for +ActiveRecord::Migration+.
|
|
8
|
+
#
|
|
9
|
+
# Provides reversible methods to add and remove money attribute columns
|
|
10
|
+
# from within a +change+ migration block.
|
|
11
|
+
#
|
|
12
|
+
# @example Adding a composite money attribute
|
|
13
|
+
# class AddPriceToProducts < ActiveRecord::Migration[8.0]
|
|
14
|
+
# def change
|
|
15
|
+
# add_money_attribute :products, :price
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Adding a single-column money amount
|
|
20
|
+
# class AddDiscountToProducts < ActiveRecord::Migration[8.0]
|
|
21
|
+
# def change
|
|
22
|
+
# add_money_amount :products, :discount, type: :fiat_integer
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
8
25
|
module SchemaStatements
|
|
9
26
|
include Helper
|
|
10
27
|
|
|
28
|
+
# Adds an amount column and a currency column for a composite money attribute.
|
|
29
|
+
#
|
|
30
|
+
# The amount column type is determined by the +:type+ option inside
|
|
31
|
+
# +amount: { type: }+ (defaults to +:fiat_decimal+). The currency column
|
|
32
|
+
# is a string with a configurable limit.
|
|
33
|
+
#
|
|
34
|
+
# @param table_name [Symbol, String] the table to alter
|
|
35
|
+
# @param accessor [Symbol, String] the money attribute accessor name
|
|
36
|
+
# @param options [Hash] migration options
|
|
37
|
+
# @option options [Hash] :amount amount column options
|
|
38
|
+
# (+:column+, +:type+, +:null+, +:default+)
|
|
39
|
+
# @option options [Hash] :currency currency column options
|
|
40
|
+
# (+:column+, +:limit+, +:null+, +:default+)
|
|
41
|
+
# @return [void]
|
|
42
|
+
#
|
|
43
|
+
# @example Default naming and type
|
|
44
|
+
# add_money_attribute :products, :price
|
|
45
|
+
# # => add_column :products, :price, :decimal, precision: 20, scale: 4
|
|
46
|
+
# # => add_column :products, :price_currency, :string, limit: 20
|
|
47
|
+
#
|
|
48
|
+
# @example Custom columns and integer type
|
|
49
|
+
# add_money_attribute :products, :price,
|
|
50
|
+
# amount: { column: :base_price, type: :fiat_integer },
|
|
51
|
+
# currency: { column: :base_currency, limit: 3 }
|
|
11
52
|
def add_money_attribute(table_name, accessor, options = {})
|
|
12
|
-
|
|
53
|
+
amount_column, currency_column, amount_opts, currency_opts = parse_money_args(accessor, options)
|
|
13
54
|
|
|
14
55
|
type = amount_opts.delete(:type)
|
|
15
|
-
add_column(table_name,
|
|
16
|
-
add_column(table_name,
|
|
56
|
+
add_column(table_name, amount_column, type, **amount_opts)
|
|
57
|
+
add_column(table_name, currency_column, :string, **currency_opts)
|
|
17
58
|
end
|
|
18
59
|
|
|
60
|
+
# Removes the amount and currency columns for a composite money attribute.
|
|
61
|
+
#
|
|
62
|
+
# Accepts the same +:amount+ and +:currency+ options as
|
|
63
|
+
# {#add_money_attribute} to identify the columns.
|
|
64
|
+
#
|
|
65
|
+
# @param table_name [Symbol, String] the table to alter
|
|
66
|
+
# @param accessor [Symbol, String] the money attribute accessor name
|
|
67
|
+
# @param options [Hash] migration options
|
|
68
|
+
# @option options [Hash] :amount amount column options (+:column+)
|
|
69
|
+
# @option options [Hash] :currency currency column options (+:column+)
|
|
70
|
+
# @return [void]
|
|
19
71
|
def remove_money_attribute(table_name, accessor, options = {})
|
|
20
|
-
|
|
72
|
+
amount_column, currency_column, = parse_money_args(accessor, options)
|
|
21
73
|
|
|
22
|
-
remove_column(table_name,
|
|
23
|
-
remove_column(table_name,
|
|
74
|
+
remove_column(table_name, amount_column)
|
|
75
|
+
remove_column(table_name, currency_column)
|
|
24
76
|
end
|
|
25
77
|
|
|
78
|
+
# Adds a single amount column for a fixed-currency money attribute.
|
|
79
|
+
#
|
|
80
|
+
# No currency column is created — the application default currency is
|
|
81
|
+
# used for all rows.
|
|
82
|
+
#
|
|
83
|
+
# @param table_name [Symbol, String] the table to alter
|
|
84
|
+
# @param accessor [Symbol, String] the money attribute accessor name
|
|
85
|
+
# @param options [Hash] column options
|
|
86
|
+
# @option options [Symbol] :column explicit column name override
|
|
87
|
+
# @option options [Symbol] :type amount type (+:fiat_decimal+,
|
|
88
|
+
# +:crypto_decimal+, +:fiat_integer+)
|
|
89
|
+
# @option options [Boolean] :null whether the column allows NULL
|
|
90
|
+
# @option options [Object] :default default value for the column
|
|
91
|
+
# @return [void]
|
|
92
|
+
#
|
|
93
|
+
# @example Default naming and type
|
|
94
|
+
# add_money_amount :products, :discount
|
|
95
|
+
# # => add_column :products, :discount, :decimal, precision: 20, scale: 4
|
|
96
|
+
#
|
|
97
|
+
# @example Integer column with explicit name
|
|
98
|
+
# add_money_amount :products, :bonus, column: :bonus_cents, type: :fiat_integer
|
|
26
99
|
def add_money_amount(table_name, accessor, options = {})
|
|
27
|
-
|
|
100
|
+
amount_column, amount_opts = parse_money_amount_args(accessor, options)
|
|
28
101
|
|
|
29
102
|
type = amount_opts.delete(:type)
|
|
30
|
-
add_column(table_name,
|
|
103
|
+
add_column(table_name, amount_column, type, **amount_opts)
|
|
31
104
|
end
|
|
32
105
|
|
|
106
|
+
# Removes the amount column for a fixed-currency money attribute.
|
|
107
|
+
#
|
|
108
|
+
# @param table_name [Symbol, String] the table to alter
|
|
109
|
+
# @param accessor [Symbol, String] the money attribute accessor name
|
|
110
|
+
# @param options [Hash] column options
|
|
111
|
+
# @option options [Symbol] :column explicit column name override
|
|
112
|
+
# @return [void]
|
|
33
113
|
def remove_money_amount(table_name, accessor, options = {})
|
|
34
114
|
remove_column(table_name, (options[:column] || accessor).to_s)
|
|
35
115
|
end
|