money_attribute 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb71d942a1dd57ae30fa3d1d8941efacf402d9346192b99cca4d0eabf69246fb
4
- data.tar.gz: 70dbb86d5d328e5d42fd09593358445d931261b59212d49f3a8e6a450d275c09
3
+ metadata.gz: a26cda78a2361b592ca6f90c1afb5f8637792b432038d45b543aa562624d6349
4
+ data.tar.gz: 0e9d78eaf7c8118942676f81f4dbebe55614663469d0d8f3095622fc0def3dca
5
5
  SHA512:
6
- metadata.gz: 93e1d50ccda57e7a535fba457e4551525923358b45ef629b381bb04c900eb1fc7b76c1aa11d18410a0669fb96f610a53df1e76e10dcc58860e1d0cd8e3dd19f6
7
- data.tar.gz: 55d8b84306bf8988e066d426e525af1d6bfb2b0f202727a9a3a38aa5a52f9461d57edac0d5c003f5996141a5e662439d661109ba6aab975fd420b882ea754a29
6
+ metadata.gz: 4ba8368b5c3c11be2375368399113b1526b1e2d9f347b2e5d16499a64577dffb5b846a876ba4d04967e8ac8a76fccc717aafb8caaefd25c83bb7dc61ce22839e
7
+ data.tar.gz: 52dd36ef69c80695f8e22802e5486332d94d5afcc4e48cf671847705b3120de5ecfb636b191fe629077d8f6ebb35a2dcbd5b6a716f031bdb918a35abe32d23fa
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/gferraz/money-attribute/actions/workflows/ci.yml/badge.svg)](https://github.com/gferraz/money-attribute/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/money_attribute.svg)](https://badge.fury.io/rb/money_attribute)
5
5
 
6
- Store and read Active Record attributes as `Mint::Money` objects with no manual serialization.
6
+ Store and read Active Record attributes as `Money` objects with no manual serialization.
7
7
 
8
8
  `money_attribute` uses two DB columns (amount + currency) for per-row multi-currency data. A simpler `money_amount` variant is also available for fixed-currency models (see [note](#single-column-mode-money_amount-fixed-currency)).
9
9
 
@@ -32,8 +32,7 @@ p.price * 2 # => [USD 24.88]
32
32
  - [Convenience methods](#convenience-methods)
33
33
  - [Form helpers](#form-helpers)
34
34
  - [Roadmap](#roadmap)
35
- - [Development & Contributing](#development)
36
- - [License](#license)
35
+ - [Development](#development)
37
36
 
38
37
  ## Quick start
39
38
 
@@ -62,13 +61,13 @@ class Product < ApplicationRecord
62
61
  end
63
62
  ```
64
63
 
65
- That's it. `Product.new(price: 12.dollars).price` is a `Mint::Money`.
64
+ That's it. `Product.new(price: 12.dollars).price` is a `Money`.
66
65
 
67
66
  ## Why MoneyAttribute?
68
67
 
69
- - **No serialization boilerplate** — declare once, read/write `Mint::Money` everywhere.
68
+ - **No serialization boilerplate** — declare once, read/write `Money` everywhere.
70
69
  - **Integer or decimal columns** — auto-detects the column type and adjusts serialization (e.g. integer stores cents, decimal stores unit value).
71
- - **Normalizes everything** — pass a number, string, or `Mint::Money`; always get a `Mint::Money` back.
70
+ - **Normalizes everything** — pass a number, string, or `Money`; always get a `Money` back.
72
71
  - **Currency enforcement** — fixed-currency attributes reject wrong currencies at assignment time.
73
72
  - **Built on Rails primitives** — uses `ActiveRecord::Type`, `composed_of`, and `normalizes` under the hood. No monkey-patching of core classes.
74
73
 
@@ -93,7 +92,7 @@ For a detailed side-by-side comparison, see [COMPARISON.md](COMPARISON.md).
93
92
 
94
93
  - Ruby 3.3+
95
94
  - Rails 7.1.3.2+
96
- - [Minting](https://github.com/gferraz/minting) 2.0+
95
+ - [Minting](https://github.com/gferraz/minting) 2.1+
97
96
 
98
97
  ## Installation
99
98
 
@@ -197,7 +196,7 @@ I18n.locale = :'pt-BR'
197
196
  Money.from(1234.56, 'USD').to_s # => "$1.234,56"
198
197
  ```
199
198
 
200
- The locale backend reads `number.currency.format` from your I18n translations and maps Rails format syntax (`%n` for amount, `%u` for unit) to `Mint::Money#to_s`. If the translation key is missing (no locale file for that language), it falls back to hardcoded defaults (`.` decimal, `,` thousand, `%<symbol>s%<amount>f` format).
199
+ The locale backend reads `number.currency.format` from your I18n translations and maps Rails format syntax (`%n` for amount, `%u` for unit) to `Money#to_s`. If the translation key is missing (no locale file for that language), it falls back to hardcoded defaults (`.` decimal, `,` thousand, `%<symbol>s%<amount>f` format).
201
200
 
202
201
  You can configure per-sign formatting by adding `positive`, `negative`, and `zero` keys to your locale:
203
202
 
@@ -304,16 +303,24 @@ end
304
303
 
305
304
  ## Column resolution
306
305
 
307
- `money_attribute :name` is always composite. It resolves columns in this order:
306
+ `money_attribute :name` is always composite. Columns are resolved in two phases:
308
307
 
309
- | Step | Condition | Columns used |
310
- |---|---|---|
311
- | 1 | `mapping:` provided | As specified (missing keys fall back to `<name>_amount` / `<name>_currency`) |
312
- | 2 | `name_currency` column exists AND `name` column exists | `name` + `name_currency` |
313
- | 3 | `name == 'amount'` AND `currency` column exists | `amount` + `currency` |
314
- | 4 | None of the above | `<name>_amount` + `<name>_currency` (convention) |
308
+ **Phase 1 Default mapping** is determined by which columns exist:
309
+
310
+ | Condition | Default columns |
311
+ |---|---|
312
+ | `name_currency` exists AND `name` exists | `name` + `name_currency` |
313
+ | `name == 'amount'` AND `currency` exists | `amount` + `currency` |
314
+ | Otherwise | `<name>_amount` + `<name>_currency` |
315
315
 
316
- Step 4 raises `ArgumentError` if the convention columns don't exist. For single-column fixed-currency attributes, see [`money_amount`](#single-column-mode-money_amount-fixed-currency).
316
+ **Phase 2 Override** via `mapping:` is merged on top of the default. Missing keys inherit from the default mapping:
317
+
318
+ ```ruby
319
+ money_attribute :total, mapping: { amount: :total_amount }
320
+ # currency falls back to default => :total_currency
321
+ ```
322
+
323
+ Raises `ArgumentError` if the resolved columns don't exist. For single-column fixed-currency attributes, see [`money_amount`](#single-column-mode-money_amount-fixed-currency).
317
324
 
318
325
  **Example**
319
326
 
@@ -392,7 +399,7 @@ Offer.create!(price: 50.dollars)
392
399
  Offer.where_amount(price: 10..50) # => both records
393
400
  ```
394
401
 
395
- For integer (subunit) columns, pass `Mint::Money` objects directly — subunit conversion is handled automatically:
402
+ For integer (subunit) columns, pass `Money` objects directly — subunit conversion is handled automatically:
396
403
 
397
404
  ```ruby
398
405
  FinancialTransaction.where_amount(amount: [10.dollars, 10.yens])
@@ -456,7 +463,7 @@ SimpleOffer.sum_amount(:price)
456
463
  # => [BRL 60.00] (single-column always returns one Money)
457
464
 
458
465
  Offer.none.sum_amount(:price)
459
- # => [Mint::Money(0, 'BRL')] (empty result returns zero Money)
466
+ # => [BRL 0.00] (empty result returns zero Money)
460
467
  ```
461
468
 
462
469
  ### Notes
@@ -491,7 +498,7 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
491
498
 
492
499
  ### Single-column mode — `money_amount` (fixed-currency)
493
500
 
494
- `money_amount` wraps a numeric column as `Mint::Money` using the application's default currency. No per-row currency. A lighter alternative when you don't need multi-currency support.
501
+ `money_amount` wraps a numeric column as `Money` using the application's default currency. No per-row currency. A lighter alternative when you don't need multi-currency support.
495
502
 
496
503
  The accessor name must match the column name. `money_amount` does not support custom column mapping.
497
504
 
@@ -505,9 +512,9 @@ The accessor name must match the column name. `money_amount` does not support cu
505
512
  Default column: `decimal(20,4)`. The top-level `type:` shortcut selects the column type:
506
513
 
507
514
  ```ruby
508
- t.money_amount :price # decimal(20,4)
515
+ t.money_amount :price # decimal(20,4)
509
516
  t.money_amount :btc_balance, type: :crypto_decimal # decimal(36,18)
510
- t.money_amount :qty, type: :fiat_integer # bigint
517
+ t.money_amount :qty, type: :fiat_integer # bigint
511
518
  ```
512
519
 
513
520
  #### Naming
@@ -564,8 +571,4 @@ The dummy Rails app under `test/dummy` exercises the engine in a full Rails envi
564
571
 
565
572
  ## Contributing
566
573
 
567
- Bug reports and pull requests welcome at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
568
-
569
- ## License
570
-
571
- [MIT](MIT-LICENSE)
574
+ Bug reports welcome at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
data/Rakefile CHANGED
@@ -15,7 +15,7 @@ Rake::TestTask.new(:test_run) do |t|
15
15
  end
16
16
 
17
17
  desc 'Migrate test database'
18
- task :test_db_migrate do # rubocop:disable Rails/RakeEnvironment
18
+ task :test_db_migrate do
19
19
  sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
20
20
  end
21
21
 
@@ -27,13 +27,13 @@ task test: %i[test_db_migrate test_run]
27
27
  %w[postgresql mysql2].each do |adapter|
28
28
  namespace :test do
29
29
  desc "Migrate test database (#{adapter})"
30
- task "db_migrate:#{adapter}" do # rubocop:disable Rails/RakeEnvironment
30
+ task "db_migrate:#{adapter}" do
31
31
  sh({ 'RAILS_ENV' => 'test', 'DATABASE_ADAPTER' => adapter },
32
32
  'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
33
33
  end
34
34
 
35
35
  desc "Run tests against #{adapter}"
36
- task adapter do # rubocop:disable Rails/RakeEnvironment
36
+ task adapter do
37
37
  Rake::Task[:"test:db_migrate:#{adapter}"].invoke
38
38
  sh({ 'DATABASE_ADAPTER' => adapter }, 'bundle', 'exec', 'rake', 'test_run')
39
39
  end
@@ -41,7 +41,7 @@ task test: %i[test_db_migrate test_run]
41
41
  end
42
42
 
43
43
  desc 'Run tests against all adapters (sqlite3, postgresql, mysql2)'
44
- task 'test:all' do # rubocop:disable Rails/RakeEnvironment
44
+ task 'test:all' do
45
45
  Rake::Task[:test].invoke
46
46
  %w[postgresql mysql2].each do |adapter|
47
47
  Rake::Task[:"test:#{adapter}"].invoke
@@ -50,7 +50,7 @@ end
50
50
 
51
51
  # --- Benchmark ---
52
52
 
53
- desc 'Run money_attribute vs money-rails benchmark'
53
+ desc 'Run money_attribute vs plain Rails vs money-rails benchmark'
54
54
  task bench: :test_db_migrate do
55
55
  puts
56
56
  puts '=' * 80
@@ -59,6 +59,13 @@ task bench: :test_db_migrate do
59
59
  sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'minting' },
60
60
  'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
61
61
 
62
+ puts
63
+ puts '=' * 80
64
+ puts 'plain Rails (raw columns, no monetization)'
65
+ puts '=' * 80
66
+ sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'plain' },
67
+ 'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
68
+
62
69
  puts
63
70
  puts '=' * 80
64
71
  puts 'money-rails (money gem)'
@@ -69,6 +76,12 @@ task bench: :test_db_migrate do
69
76
  end
70
77
 
71
78
  desc 'Generate consolidated benchmark report (markdown)'
72
- task 'bench:report' do # rubocop:disable Rails/RakeEnvironment
79
+ task 'bench:report' do
73
80
  ruby 'benchmark/report.rb'
74
81
  end
82
+
83
+ desc 'Profile hot paths with stackprof (MODE=string_query|pluck|read_cached|multi_record|arithmetic|all)'
84
+ task 'bench:profile' do
85
+ mode = ENV['MODE'] || 'all'
86
+ sh({ 'RAILS_ENV' => 'test' }, 'bundle', 'exec', 'ruby', 'benchmark/profile.rb', mode)
87
+ end
@@ -1,12 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
+ # Generator namespace for money_attribute.
4
5
  module Generators
6
+ # Rails generator that installs the money_attribute initializer.
7
+ #
8
+ # @example
9
+ # rails generate money_attribute:initializer
10
+ #
11
+ # @api private
5
12
  class InitializerGenerator < ::Rails::Generators::Base
6
13
  source_root File.expand_path('../templates', __dir__)
7
14
 
8
15
  desc 'Creates MoneyAttribute initializer.'
9
16
 
17
+ # Copies the initializer template into the application.
18
+ #
19
+ # @return [void]
10
20
  def copy_initializer
11
21
  copy_file 'money_attribute.rb', 'config/initializers/money_attribute.rb'
12
22
  end
@@ -21,7 +21,9 @@ module MoneyAttribute
21
21
  #
22
22
  # Created by +money_attribute+ or +money_amount+ and stored in the class-level registry.
23
23
  # Used by query helpers to resolve column names, build Money values, and generate SQL.
24
- AttributeSpec = Struct.new(:name, :kind, :amount_col, :currency_col, :amount_type, keyword_init: true) do
24
+ #
25
+ # @api private
26
+ AttributeSpec = Struct.new(:name, :kind, :amount_column, :currency_column, :amount_type, keyword_init: true) do
25
27
  # @return [Boolean] +true+ when the spec describes a two-column (amount + currency) attribute.
26
28
  def composite? = kind == :composite
27
29
 
@@ -32,7 +34,7 @@ module MoneyAttribute
32
34
  #
33
35
  # @return [Array<String>] two-element array for composite, one-element for single.
34
36
  def columns
35
- @columns ||= (composite? ? [amount_col, currency_col] : [amount_col]).freeze
37
+ @columns ||= (composite? ? [amount_column, currency_column] : [amount_column]).freeze
36
38
  end
37
39
 
38
40
  # @return [Boolean] +true+ when the amount column stores subunits (bigint).
@@ -42,7 +44,9 @@ module MoneyAttribute
42
44
  def amount_extractor = integer_amount? ? :subunits : :to_d
43
45
 
44
46
  # @return [Hash{String => Symbol}] mapping suitable for +composed_of+.
45
- def composed_of_mapping = { amount_col => amount_extractor, currency_col => :currency_code }
47
+ def composed_of_mapping
48
+ @composed_of_mapping ||= { amount_column => amount_extractor, currency_column => :currency_code }.freeze
49
+ end
46
50
 
47
51
  # @return [Proc] the constructor lambda used by +composed_of+ to instantiate Money values.
48
52
  def constructor
@@ -54,6 +58,7 @@ module MoneyAttribute
54
58
  # @param amount [Integer, BigDecimal, nil] the raw column value
55
59
  # @param currency [String, Mint::Currency, nil] the currency code or object
56
60
  # @return [Mint::Money, nil] the resolved money value, or nil when amount is nil
61
+ # @api private
57
62
  def build_money(amount, currency)
58
63
  return unless amount
59
64
  return amount if amount.is_a?(Mint::Money)
@@ -68,6 +73,7 @@ module MoneyAttribute
68
73
  #
69
74
  # @param value [Mint::Money, Numeric] the query value
70
75
  # @return [Integer, BigDecimal, Mint::Money] the normalized value
76
+ # @api private
71
77
  def normalize_query_value(value)
72
78
  return value unless integer_amount?
73
79
  return value.subunits if value.is_a?(Mint::Money)
@@ -4,26 +4,33 @@ 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
11
17
 
12
18
  class_methods do
13
19
  # Registers a money attribute spec for the current model class.
14
20
  #
15
21
  # @param name [Symbol, String] the attribute name
16
22
  # @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)
23
+ # @param amount_column [Symbol, String] the amount column name
24
+ # @param currency_column [Symbol, String, nil] the currency column name (composite only)
19
25
  # @param amount_type [Symbol, nil] +:integer+ or +:decimal+
20
26
  # @return [AttributeSpec]
21
- def register_money_attribute_spec(name, kind:, amount_col:, currency_col: nil, amount_type: nil)
27
+ # @api private
28
+ def register_money_attribute_spec(name, kind:, amount_column:, currency_column: nil, amount_type: nil)
22
29
  spec = MoneyAttribute::AttributeSpec.new(
23
30
  name: name.to_s,
24
31
  kind: kind,
25
- amount_col: amount_col.to_s,
26
- currency_col: currency_col&.to_s,
32
+ amount_column: amount_column.to_s,
33
+ currency_column: currency_column&.to_s,
27
34
  amount_type: amount_type
28
35
  )
29
36
 
@@ -35,6 +42,7 @@ module MoneyAttribute
35
42
  #
36
43
  # @param name [Symbol, String] the attribute name
37
44
  # @return [AttributeSpec, nil]
45
+ # @api private
38
46
  def money_attribute_spec(name)
39
47
  REGISTRY[self]&.fetch(name.to_s, nil)
40
48
  end
@@ -42,9 +50,29 @@ module MoneyAttribute
42
50
  # Returns the registry hash for the current model class.
43
51
  #
44
52
  # @return [Hash{String => AttributeSpec}]
53
+ # @api private
45
54
  def money_attribute_specs
46
55
  REGISTRY.fetch_or_store(self) { {} }
47
56
  end
57
+
58
+ # Returns a frozen Set of registered money attribute names.
59
+ #
60
+ # @return [Set<String>]
61
+ # @api private
62
+ def money_attribute_names_set
63
+ PATTERNS.fetch_or_store(:"#{self}_name_set") { money_attribute_specs.keys.to_set.freeze }
64
+ end
65
+
66
+ # Returns a pre-compiled regex matching any registered money attribute name.
67
+ #
68
+ # @return [Regexp]
69
+ # @api private
70
+ def money_attribute_name_pattern
71
+ PATTERNS.fetch_or_store(:"#{self}_name_pattern") do
72
+ names = money_attribute_specs.keys.map { |n| Regexp.escape(n) }
73
+ /\b(#{names.join('|')})\b/i
74
+ end
75
+ end
48
76
  end
49
77
  end
50
78
  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,21 +1,36 @@
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
@@ -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
 
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
@@ -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?
@@ -59,6 +102,20 @@ module MoneyAttribute
59
102
 
60
103
  class_methods do
61
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
62
119
  def money_attribute(name, mapping: {})
63
120
  mapping = resolve_mapping(name, mapping)
64
121
  spec = register_composite_spec(name, mapping)