money_attribute 1.2.0 → 1.3.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.
@@ -4,30 +4,41 @@ require 'concurrent/map'
4
4
 
5
5
  module MoneyAttribute
6
6
  # Stores money attribute metadata on the model class.
7
+ #
8
+ # Holds a per-model registry of {AttributeSpec} objects plus derived caches
9
+ # (name set, regex patterns) populated lazily on first access.
10
+ #
11
+ # @api private
7
12
  module AttributeSpecRegistry
8
13
  extend ActiveSupport::Concern
9
14
 
10
15
  REGISTRY = Concurrent::Map.new
16
+ PATTERNS = Concurrent::Map.new
17
+ QUERY_PLANS = Concurrent::Map.new
11
18
 
12
19
  class_methods do
13
20
  # Registers a money attribute spec for the current model class.
14
21
  #
15
22
  # @param name [Symbol, String] the attribute name
16
23
  # @param kind [Symbol] +:composite+ or +:single+
17
- # @param amount_col [Symbol, String] the amount column name
18
- # @param currency_col [Symbol, String, nil] the currency column name (composite only)
24
+ # @param amount_column [Symbol, String] the amount column name
25
+ # @param currency_column [Symbol, String, nil] the currency column name (composite only)
19
26
  # @param amount_type [Symbol, nil] +:integer+ or +:decimal+
20
27
  # @return [AttributeSpec]
21
- def register_money_attribute_spec(name, kind:, amount_col:, currency_col: nil, amount_type: nil)
28
+ # @api private
29
+ def register_money_attribute_spec(name, kind:, amount_column:, currency_column: nil, amount_type: nil)
22
30
  spec = MoneyAttribute::AttributeSpec.new(
23
31
  name: name.to_s,
24
32
  kind: kind,
25
- amount_col: amount_col.to_s,
26
- currency_col: currency_col&.to_s,
33
+ amount_column: amount_column.to_s,
34
+ currency_column: currency_column&.to_s,
27
35
  amount_type: amount_type
28
36
  )
29
37
 
30
38
  money_attribute_specs[spec.name] = spec
39
+ PATTERNS.delete(:"#{self}_name_set")
40
+ PATTERNS.delete(:"#{self}_name_pattern")
41
+ QUERY_PLANS.delete(self)
31
42
  spec
32
43
  end
33
44
 
@@ -35,6 +46,7 @@ module MoneyAttribute
35
46
  #
36
47
  # @param name [Symbol, String] the attribute name
37
48
  # @return [AttributeSpec, nil]
49
+ # @api private
38
50
  def money_attribute_spec(name)
39
51
  REGISTRY[self]&.fetch(name.to_s, nil)
40
52
  end
@@ -42,9 +54,55 @@ module MoneyAttribute
42
54
  # Returns the registry hash for the current model class.
43
55
  #
44
56
  # @return [Hash{String => AttributeSpec}]
57
+ # @api private
45
58
  def money_attribute_specs
46
59
  REGISTRY.fetch_or_store(self) { {} }
47
60
  end
61
+
62
+ # Returns a frozen Set of registered money attribute names.
63
+ #
64
+ # @return [Set<String>]
65
+ # @api private
66
+ def money_attribute_names_set
67
+ PATTERNS.fetch_or_store(:"#{self}_name_set") { money_attribute_specs.keys.to_set.freeze }
68
+ end
69
+
70
+ # Returns a pre-compiled regex matching any registered money attribute name.
71
+ #
72
+ # @return [Regexp]
73
+ # @api private
74
+ def money_attribute_name_pattern
75
+ PATTERNS.fetch_or_store(:"#{self}_name_pattern") do
76
+ names = money_attribute_specs.keys.map { |n| Regexp.escape(n) }
77
+ /\b(#{names.join('|')})\b/i
78
+ end
79
+ end
80
+ end
81
+
82
+ class_methods do
83
+ # Returns whether the model has a registered money attribute.
84
+ #
85
+ # @param name [Symbol, String] the attribute name
86
+ # @return [Boolean]
87
+ def money_attribute?(name)
88
+ !money_attribute_spec(name).nil?
89
+ end
90
+
91
+ # Returns the storage mode for a registered money attribute.
92
+ #
93
+ # @param name [Symbol, String] the attribute name
94
+ # @return [Symbol, nil] +:composite+, +:single+, or +nil+
95
+ def money_attribute_kind(name)
96
+ money_attribute_spec(name)&.kind
97
+ end
98
+
99
+ # Returns the compiled string-query cache for the current model class.
100
+ #
101
+ # @return [Concurrent::Map]
102
+ # @api private
103
+ def money_attribute_query_plan_cache
104
+ QUERY_PLANS.fetch_or_store(self) { Concurrent::Map.new }
105
+ end
48
106
  end
49
107
  end
50
108
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # Shared column-type validation for the +money_attribute+ and +money_amount+
5
+ # macros.
6
+ #
7
+ # Included by {Macro::CompositeClassMethods} and {MoneyAmount}. Raises when a
8
+ # backing column uses a type that cannot store a money amount or currency code.
9
+ #
10
+ # @api private
11
+ module ColumnTypeValidations
12
+ VALID_AMOUNT_TYPES = %i[integer bigint decimal].freeze
13
+ VALID_CURRENCY_TYPES = %i[string text].freeze
14
+
15
+ # Validates that a column can store a money amount.
16
+ #
17
+ # @param attr_name [Symbol, String] the money attribute accessor name
18
+ # @param column_name [Symbol, String] the amount column name
19
+ # @param column [ActiveRecord::ConnectionAdapters::Column] the column metadata
20
+ # @return [void]
21
+ # @raise [ArgumentError] if the column type is not numeric
22
+ # @api private
23
+ def assert_valid_amount_column!(attr_name, column_name, column)
24
+ return if VALID_AMOUNT_TYPES.include?(column.type)
25
+
26
+ raise ArgumentError,
27
+ "`:#{attr_name}` amount column `#{column_name}` must be a numeric type " \
28
+ "(integer, bigint, or decimal), got `#{column.type}`"
29
+ end
30
+
31
+ # Validates that a column can store a currency code.
32
+ #
33
+ # @param attr_name [Symbol, String] the money attribute accessor name
34
+ # @param column_name [Symbol, String] the currency column name
35
+ # @param column [ActiveRecord::ConnectionAdapters::Column] the column metadata
36
+ # @return [void]
37
+ # @raise [ArgumentError] if the column type is not string-like
38
+ # @api private
39
+ def assert_valid_currency_column!(attr_name, column_name, column)
40
+ return if VALID_CURRENCY_TYPES.include?(column.type)
41
+
42
+ raise ArgumentError,
43
+ "`:#{attr_name}` currency column `#{column_name}` must be a string type " \
44
+ "(string or text), got `#{column.type}`"
45
+ end
46
+ end
47
+ end
@@ -1,27 +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
10
  DEFAULT = new.freeze
7
11
 
8
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]
9
17
  def initialize(currency = nil)
10
18
  @static_currency = currency
11
19
  end
12
20
 
13
21
  # Returns the shared default converter instance.
22
+ #
23
+ # @return [Converter] the frozen, process-wide converter
14
24
  def self.default
15
25
  DEFAULT
16
26
  end
17
27
 
18
- # Converts raw input into a `Money` value.
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
19
34
  def parse(amount)
20
35
  currency = @static_currency || MoneyAttribute.default_currency
21
36
  case amount
22
37
  when Money, NilClass then amount
23
38
  when Numeric then Money.from(amount, currency)
24
- when String then Money.parse(amount, currency)
39
+ when String then Money.parse(amount, default_currency: currency)
25
40
  else raise ArgumentError, "Cannot convert #{amount.inspect} (#{amount.class}) to Money"
26
41
  end
27
42
  end
@@ -1,8 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # :nodoc:
3
+ # Convenience method for converting numeric values to +Mint::Money+.
4
+ #
5
+ # @api private
4
6
  class Numeric
5
7
  remove_method :to_money if method_defined?(:to_money)
6
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')
7
16
  def to_money(currency = MoneyAttribute.default_currency) = Money.from(self, currency)
8
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
 
7
- def to_money(currency = MoneyAttribute.default_currency) = Money.parse(self, currency)
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')
17
+ def to_money(currency = MoneyAttribute.default_currency) = Money.parse(self, default_currency: currency)
8
18
  end
@@ -1,9 +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
6
- # Renders a text input for a composed money attribute.
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"
7
37
  def money_field(method, options = {})
8
38
  money = object.public_send(method)
9
39
  value = money&.to_fs
@@ -12,7 +42,22 @@ module MoneyAttribute
12
42
  { id: field_id(method) }.merge(options))
13
43
  end
14
44
 
15
- # Renders a number input for a single-column money attribute.
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
16
61
  def money_amount_field(method, options = {})
17
62
  money_from_column = object.public_send(method)
18
63
  value = money_from_column&.to_d
@@ -1,13 +1,32 @@
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
21
+ include ColumnTypeValidations
22
+
10
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
11
30
  def resolve_mapping(name, mapping_override)
12
31
  override = mapping_override.compact
13
32
  override.slice!(:amount, :currency)
@@ -20,6 +39,13 @@ module MoneyAttribute
20
39
  end
21
40
 
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
23
49
  def default_mapping(name)
24
50
  name = name.to_s
25
51
  names = column_names
@@ -34,18 +60,35 @@ module MoneyAttribute
34
60
  end
35
61
 
36
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
37
69
  def register_composite_spec(name, mapping)
38
- column = column_for_attribute(mapping[:amount])
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
+
39
76
  register_money_attribute_spec(
40
77
  name,
41
78
  kind: :composite,
42
- amount_col: mapping[:amount],
43
- currency_col: mapping[:currency],
44
- amount_type: %i[integer bigint].include?(column.type) ? :integer : :decimal
79
+ amount_column: mapping[:amount],
80
+ currency_column: mapping[:currency],
81
+ amount_type: %i[integer bigint].include?(amount_column.type) ? :integer : :decimal
45
82
  )
46
83
  end
47
84
 
48
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
49
92
  def assert_columns_exist!(name, mapping)
50
93
  missing = mapping.values - column_names
51
94
  return if missing.empty?
@@ -55,11 +98,57 @@ module MoneyAttribute
55
98
  "Expected: #{mapping.values.join(', ')}, " \
56
99
  "Found: #{attribute_names.join(', ')}"
57
100
  end
101
+
102
+ # Normalizes migration-style column options to the legacy mapping format.
103
+ #
104
+ # @param mapping [Hash] the user-supplied +mapping:+ option
105
+ # @param amount [Hash, nil] migration-style amount options
106
+ # @param currency [Hash, nil] migration-style currency options
107
+ # @return [Hash] normalized column mapping
108
+ # @raise [ArgumentError] if options are ambiguous or unsupported
109
+ # @api private
110
+ def normalize_mapping(mapping, amount, currency)
111
+ mapping = mapping.compact
112
+ nested = { amount:, currency: }.compact
113
+ conflicts = mapping.keys & nested.keys
114
+ unless conflicts.empty?
115
+ raise ArgumentError, "Specify #{conflicts.join(', ')} using either mapping: or nested options, not both"
116
+ end
117
+
118
+ nested.each do |key, options|
119
+ mapping[key] = normalize_nested_column(key, options)
120
+ end
121
+
122
+ mapping
123
+ end
124
+
125
+ def normalize_nested_column(key, options)
126
+ return options[:column] if options.is_a?(Hash) && options.keys == [:column] && options[:column]
127
+
128
+ raise ArgumentError, "#{key}: must be a hash containing only a non-empty :column option"
129
+ end
58
130
  end
59
131
 
60
132
  class_methods do
61
133
  # Declares a composite money attribute on the model.
62
- def money_attribute(name, mapping: {})
134
+ #
135
+ # Stores the attribute across two columns (amount + currency). The amount
136
+ # column type determines the storage unit: integer/bigint stores subunits,
137
+ # decimal stores the unit value. Currency is resolved per row.
138
+ #
139
+ # @param name [Symbol, String] the money attribute accessor name
140
+ # @param mapping [Hash] custom column mapping (+:amount+, +:currency+)
141
+ # @param amount [Hash, nil] migration-style amount options (+:column+ only)
142
+ # @param currency [Hash, nil] migration-style currency options (+:column+ only)
143
+ # @return [void]
144
+ #
145
+ # @example
146
+ # class Product < ApplicationRecord
147
+ # money_attribute :price
148
+ # money_attribute :price, mapping: { amount: :base_price, currency: :base_currency }
149
+ # end
150
+ def money_attribute(name, mapping: {}, amount: nil, currency: nil)
151
+ mapping = normalize_mapping(mapping, amount, currency)
63
152
  mapping = resolve_mapping(name, mapping)
64
153
  spec = register_composite_spec(name, mapping)
65
154
 
@@ -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)
@@ -35,6 +60,17 @@ module MoneyAttribute
35
60
  [column, config.merge(options)]
36
61
  end
37
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
38
74
  def parse_currency_args(accessor, options)
39
75
  options ||= {}
40
76
  limit = (options[:limit] || CURRENCY_DEFAULT_LIMIT).to_i
@@ -49,6 +85,18 @@ module MoneyAttribute
49
85
  [column, { limit:, null: options[:null], default: options[:default] }.compact]
50
86
  end
51
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
52
100
  def currency_column_name(accessor, column_override)
53
101
  return column_override.to_s if column_override
54
102
 
@@ -59,6 +107,18 @@ module MoneyAttribute
59
107
  "#{radical}_currency"
60
108
  end
61
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
62
122
  def parse_money_args(accessor, options = {})
63
123
  amount_column, amount_options = parse_money_amount_args(accessor, options[:amount])
64
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