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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +143 -41
  3. data/Rakefile +43 -2
  4. data/lib/generators/money_attribute/initializer_generator.rb +10 -0
  5. data/lib/generators/templates/money_attribute.rb +20 -12
  6. data/lib/money_attribute/attribute_spec.rb +84 -0
  7. data/lib/money_attribute/attribute_spec_registry.rb +78 -0
  8. data/lib/money_attribute/column_type_validations.rb +47 -0
  9. data/lib/money_attribute/configuration.rb +43 -15
  10. data/lib/money_attribute/converter.rb +30 -5
  11. data/lib/money_attribute/core_ext/numeric.rb +17 -0
  12. data/lib/money_attribute/core_ext/string.rb +11 -1
  13. data/lib/money_attribute/current.rb +13 -0
  14. data/lib/money_attribute/form_builder_extension.rb +48 -1
  15. data/lib/money_attribute/macro.rb +106 -72
  16. data/lib/money_attribute/migration_extensions/helper.rb +62 -3
  17. data/lib/money_attribute/migration_extensions/schema_statements.rb +89 -9
  18. data/lib/money_attribute/migration_extensions/table_definition.rb +68 -11
  19. data/lib/money_attribute/money_amount.rb +49 -25
  20. data/lib/money_attribute/query/amount_condition.rb +207 -0
  21. data/lib/money_attribute/query/amount_order.rb +28 -0
  22. data/lib/money_attribute/query/currency_condition.rb +29 -0
  23. data/lib/money_attribute/query/helpers.rb +53 -0
  24. data/lib/money_attribute/query/pick.rb +41 -0
  25. data/lib/money_attribute/query/pluck.rb +39 -0
  26. data/lib/money_attribute/query/sum.rb +52 -0
  27. data/lib/money_attribute/query.rb +125 -0
  28. data/lib/money_attribute/railtie.rb +47 -1
  29. data/lib/money_attribute/type.rb +41 -33
  30. data/lib/money_attribute/version.rb +2 -1
  31. data/lib/money_attribute.rb +16 -1
  32. metadata +17 -5
  33. 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
- class Configuration
5
- attr_accessor :added_currencies, :default_currency
4
+ # @private
5
+ CONFIG_MUTEX = Mutex.new
6
6
 
7
- def initialize
8
- @added_currencies = []
9
- @default_currency = 'USD'
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
- cfg = Configuration.new
14
- cached_default = nil
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
- define_singleton_method(:config) { cfg }
28
+ last_code, last_currency = Thread.current[:money_attribute_default_currency]
29
+ return last_currency if last_code == code
17
30
 
18
- define_singleton_method(:configure) do |&block|
19
- block&.call(cfg)
20
- cached_default = nil
21
- cfg
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
- define_singleton_method(:default_currency) do
25
- cached_default ||= ::Mint::Currency.resolve!(cfg.default_currency)
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
- # :nodoc:
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
- def initialize(currency = MoneyAttribute.default_currency)
7
- @default_currency = currency
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, @default_currency)
14
- when String then Money.parse(amount, @default_currency)
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
- # :nodoc:
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
- # :nodoc:
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>&lt;input&gt;</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>&lt;input type="text"&gt;</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>&lt;input type="number"&gt;</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
- # :nodoc:
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
- # :nodoc:
17
+ # Class methods backing the +money_attribute+ macro.
18
+ #
19
+ # @api private
9
20
  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)
14
-
15
- nil
16
- elsif name == 'amount' && columns.include?('currency')
17
- { amount: name, currency: :currency }
18
- end
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
- def resolve_composite_for(name, mapping:)
22
- composite = { amount: "#{name}_amount", currency: "#{name}_currency" }
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
- composite[:amount] = mapping[:amount].to_s if mapping&.key?(:amount)
25
- composite[:currency] = mapping[:currency].to_s if mapping&.key?(:currency)
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
- assert_columns_exist!(name, composite)
28
- composite
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
- def assert_columns_exist!(name, composite)
32
- missing = composite.values - attribute_names
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: #{composite.values.join(', ')}, " \
98
+ "Expected: #{mapping.values.join(', ')}, " \
38
99
  "Found: #{attribute_names.join(', ')}"
39
100
  end
101
+ end
40
102
 
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:)
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: 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
- }
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
- # :nodoc:
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
- # :nodoc:
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
- amount_col, currency_col, amount_opts, currency_opts = parse_money_args(accessor, options)
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, amount_col, type, **amount_opts)
16
- add_column(table_name, currency_col, :string, **currency_opts)
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
- amount_col, currency_col, = parse_money_args(accessor, options)
72
+ amount_column, currency_column, = parse_money_args(accessor, options)
21
73
 
22
- remove_column(table_name, amount_col)
23
- remove_column(table_name, currency_col)
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
- amount_col, amount_opts = parse_money_amount_args(accessor, options)
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, amount_col, type, **amount_opts)
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