minting 1.8.2 → 1.9.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.
@@ -1,29 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mint
4
- # Formatting functionality for Money objects
4
+ # :nodoc:
5
5
  class Money
6
6
  private
7
7
 
8
- # Resolves format/decimal/thousand from locale_backend when not explicitly given.
8
+ # Resolves the format template and amount based on the amount's sign
9
+ # (negative_format/zero_format may override both the template and negate the value)
9
10
  # @private
10
- def resolve_locale_for(format, decimal, thousand)
11
- locale = locale_backend
12
- [format || locale[:format] || '%<symbol>s%<amount>f',
13
- decimal || locale[:decimal] || '.',
14
- thousand || locale[:thousand] || ',']
15
- end
16
-
17
- def locale_backend
18
- bk = Mint.locale_backend
19
- return {} unless bk.respond_to?(:call)
20
-
21
- bk.call
22
- end
23
-
24
- # Selects the appropriate format template and value based on the amount's sign.
25
- # @private
26
- def select_format(format)
11
+ def resolve_format(format)
27
12
  negative_format = format[:negative]
28
13
  zero_format = format[:zero]
29
14
 
@@ -32,7 +17,7 @@ module Mint
32
17
  elsif amount.zero? && zero_format
33
18
  [zero_format, amount]
34
19
  else
35
- [format[:positive], amount]
20
+ [format[:positive] || '%<symbol>s%<amount>f', amount]
36
21
  end
37
22
  end
38
23
 
@@ -44,17 +29,62 @@ module Mint
44
29
  raise ArgumentError, "Unknown format parameter(s): #{unknown.inspect}. " unless unknown.empty?
45
30
  end
46
31
 
32
+ # Validates +decimal+ and +thousand+ separator arguments.
33
+ # @private
34
+ def validate_separators!(decimal:, thousand:)
35
+ case decimal
36
+ when '' then raise ArgumentError, 'decimal must be a non-empty'
37
+ when nil # :noop
38
+ when String
39
+ raise ArgumentError, "decimal and thousand cannot be identical: #{decimal.inspect}" if decimal == thousand
40
+ else raise ArgumentError, "decimal must be a String, false, or nil, got #{decimal.inspect}"
41
+ end
42
+
43
+ case thousand
44
+ when false, nil, String # :noop
45
+ else raise ArgumentError, "thousand must be a String, false, or nil, got #{thousand.inspect}"
46
+ end
47
+ end
48
+
49
+ def apply_thousand_separator(string, decimal:, thousand:)
50
+ return string if !thousand || thousand.empty?
51
+
52
+ # Apply thousands only to the integral portion, using the decimal as boundary
53
+ parts = string.split(/(?<=\d)#{Regexp.escape(decimal)}(?=\d)/, 2)
54
+ parts[0].gsub!(/(\d)(?=(?:\d{3})+(?:[^\d]|$))/, "\\1#{thousand}")
55
+ parts.join(decimal)
56
+ end
57
+
47
58
  # Applies a format template to produce a formatted string representation.
48
59
  # @private
49
- def format_amount(format)
50
- format, value = select_format(format)
51
- format ||= '%<symbol>s%<amount>f'
52
- # Automatically adjust decimal places based on currency subunit if missing
53
- format = format.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{currency.subunit}f")
54
-
55
- refs = format.scan(/%<(\w+)>/).flatten.map(&:to_sym)
56
- all_args = { amount: value, currency: currency_code, symbol: currency.symbol }
57
- Kernel.format(format, **all_args.slice(*refs))
60
+ #
61
+ def format_amount(format, decimal:, thousand:)
62
+ subunit = currency.subunit
63
+ resolved_format, adjusted_amount = resolve_format(format)
64
+
65
+ # Inject the currency's subunit precision into %<amount>f specifiers
66
+ # e.g. '%<amount>f' becomes '%<amount>.2f' for USD
67
+ resolved_format = resolved_format.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{subunit}f")
68
+
69
+ # Zero-subunit currencies (e.g. JPY) have no fractional part —
70
+ # strip %<fractional>d specifiers entirely since there's no valid integer for "nothing"
71
+ resolved_format.gsub!(/%<fractional>[^%]*?d/, '') if subunit.zero?
72
+
73
+ result = Kernel.format(resolved_format, {
74
+ amount: adjusted_amount,
75
+ currency: currency_code,
76
+ symbol: currency.symbol,
77
+ integral: adjusted_amount.to_i,
78
+ fractional: fractional
79
+ })
80
+
81
+ # Substitute decimal first, while the dot is still unambiguous
82
+ result.gsub!(/(?<=\d)\.(?=\d)/, decimal) if decimal != '.'
83
+
84
+ return result if adjusted_amount.abs < 1000
85
+
86
+ # Apply thousands only to the integral portion, using the decimal as boundary
87
+ apply_thousand_separator(result, decimal:, thousand:)
58
88
  end
59
89
  end
60
90
  end
@@ -1,14 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mint
4
- # Formatting functionality for Money objects
4
+ # :nodoc:
5
5
  class Money
6
+ PRESETS = {
7
+ amount: { format: '%<amount>f' },
8
+ accounting: { format: { negative: '(%<symbol>s%<amount>f)' } },
9
+ european: { format: '%<amount>f %<symbol>s', decimal: ',', thousand: '.' },
10
+ currency: { format: '%<currency>s %<amount>f' }
11
+ }.freeze
12
+
6
13
  # Formats money as a string with customizable format, thousand delimiter, and decimal
7
14
  #
15
+ # @param preset [Symbol, nil] Named format preset, one of:
16
+ # +:accounting+, +:european+, +:amount+, +:currency+.
17
+ # When provided, expands to the preset's format options and merges
18
+ # with any explicit keyword arguments (kwargs override the preset).
8
19
  # @param format [String, Hash, nil] Either a Format string with placeholders
9
- # (%<symbol>s, %<amount>f, %<currency>s), or a Hash with per-sign keys
10
- # (:positive, :negative, :zero) each holding a format string. A Hash
11
- # is convenient for sign-aware formats such as accounting parentheses:
20
+ # (%<symbol>s, %<amount>f, %<currency>s, %<integral>d, %<fractional>d),
21
+ # or a Hash with per-sign keys (:positive, :negative, :zero) each
22
+ # holding a format string. A Hash is convenient for sign-aware formats
23
+ # such as accounting parentheses:
12
24
  #
13
25
  # money.to_s(format: { negative: '(%<symbol>s%<amount>f)' })
14
26
  #
@@ -23,8 +35,9 @@ module Mint
23
35
  # When +nil+, falls back to +Mint.locale_backend+ if set, otherwise +"."+.
24
36
  # @return [String] Formatted money string
25
37
  #
26
- # @raise [ArgumentError] if +format+ is not a String or Hash, the Hash
27
- # is empty, or the Hash contains an unrecognised key.
38
+ # @raise [ArgumentError] if +preset+ is not a recognised name, or if
39
+ # +format+ is not a String or Hash, the Hash is empty, or the Hash
40
+ # contains an unrecognised key.
28
41
  #
29
42
  # @example Basic formatting
30
43
  # money = Mint.money(1234.56, 'USD')
@@ -32,12 +45,25 @@ module Mint
32
45
  # money.to_s(thousand: '.', decimal: ',') #=> "$1.234,56"
33
46
  # money.to_s(decimal: ',', thousand: '') #=> "$1234,56"
34
47
  #
48
+ # @example Preset formats
49
+ # loss = Mint.money(-1234.56, 'USD')
50
+ # loss.to_s(:accounting) #=> "($1,234.56)"
51
+ # money.to_s(:european) #=> "1.234,56 €"
52
+ # money.to_s(:amount) #=> "1234.56"
53
+ # money.to_s(:currency) #=> "USD 1234.56"
54
+ #
35
55
  # @example Custom formats
36
56
  # money.to_s(format: '%<amount>f') #=> "1234.56"
37
57
  # money.to_s(format: '%<currency>s %<amount>f') #=> "USD 1234.56"
38
58
  # money.to_s(format: '%<amount>f %<symbol>s') #=> "1234.56 $"
39
59
  # money.to_s(format: '%<symbol>s%<amount>+f') #=> "$+1234.56"
40
60
  #
61
+ # @example Integral & fractional parts
62
+ # money.to_s(format: '%<integral>d.%<fractional>02d') #=> "1234.56"
63
+ # price = Mint.money(0.99, 'USD')
64
+ # price.to_s(format: '%<integral>d dollars and %<fractional>02d cents')
65
+ # #=> "0 dollars and 99 cents"
66
+ #
41
67
  # @example Per-sign Hash format (accounting parentheses)
42
68
  # loss = Mint.money(-1234.56, 'USD')
43
69
  # loss.to_s(format: { negative: '(%<symbol>s%<amount>f)' }) #=> "($1,234.56)"
@@ -50,7 +76,17 @@ module Mint
50
76
  # @example Locale-aware formatting (with Mint.locale_backend set)
51
77
  # money.to_s # decimal and thousand come from locale_backend
52
78
  #
53
- def to_s(format: nil, decimal: nil, thousand: nil, width: nil)
79
+ def to_s(preset = nil, format: nil, decimal: nil, thousand: nil, width: nil)
80
+ if preset
81
+ config = PRESETS.fetch(preset) { raise ArgumentError, "Unknown format preset: #{preset.inspect}" }
82
+ format ||= config[:format]
83
+ decimal ||= config[:decimal]
84
+ thousand ||= config[:thousand]
85
+ width ||= config[:width]
86
+ end
87
+
88
+ validate_separators!(decimal:, thousand:)
89
+
54
90
  format, decimal, thousand = resolve_locale_for(format, decimal, thousand)
55
91
 
56
92
  case format
@@ -60,15 +96,7 @@ module Mint
60
96
  else raise ArgumentError, 'Invalid format. Only String or Hash are accepted'
61
97
  end
62
98
 
63
- formatted = format_amount(format)
64
-
65
- formatted.tr!('.', decimal) if decimal != '.'
66
-
67
- unless thousand.empty?
68
- # Regular expression courtesy of Money gem
69
- # Matches digits followed by groups of 3 digits until non-digit or end
70
- formatted.gsub!(/(\d)(?=(?:\d{3})+(?:[^\d]{1}|$))/, "\\1#{thousand}")
71
- end
99
+ formatted = format_amount(format, decimal: decimal, thousand: thousand)
72
100
 
73
101
  width ? formatted.rjust(width) : formatted
74
102
  end
@@ -13,7 +13,9 @@ require_relative 'format/formatting'
13
13
  require_relative 'format/to_s'
14
14
 
15
15
  module Mint
16
- # Money constructors
16
+ # Represents a monetary value paired with a currency.
17
+ # Money objects are immutable and support arithmetic, comparison,
18
+ # formatting, allocation, and parsing operations.
17
19
  class Money
18
20
  # The default display format pattern for formatting monetary values.
19
21
  # Uses `%<symbol>s` for the currency symbol and `%<amount>f` for the rounded amount.
@@ -33,10 +35,17 @@ module Mint
33
35
  #
34
36
  # @return [Integer] the amount in fractional units
35
37
  # @example
36
- # Mint.money(1234.56, 'USD').fractional #=> 123456
37
- # Mint.money(1000, 'JPY').fractional #=> 1000
38
- # Mint.money(123.456, 'IQD').fractional #=> 123456
39
- def fractional = (amount * currency.fractional_multiplier).to_i
38
+ # Mint.money(1234.56, 'USD').subunits #=> 123456
39
+ # Mint.money(1000, 'JPY').subunits #=> 1000
40
+ # Mint.money(123.456, 'IQD').subunits #=> 123456
41
+ def subunits = (amount * currency.fractional_multiplier).to_i
42
+
43
+ # Returns the fractional part of the amount.
44
+ # @example
45
+ # Mint.money(1234.56, 'USD').fractional #=> 56
46
+ # Mint.money(1000, 'JPY').fractional #=> 0
47
+ # Mint.money(123.456, 'IQD').fractional #=> 456
48
+ def fractional = ((amount.abs % 1) * currency.fractional_multiplier).to_i
40
49
 
41
50
  # Generates a stable hash key for Money instances.
42
51
  #
@@ -3,5 +3,5 @@
3
3
  # Root namespace for the Minting library.
4
4
  module Minting
5
5
  # Current version of the Minting gem.
6
- VERSION = '1.8.2'
6
+ VERSION = '1.9.0'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minting
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.2
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gilson Ferraz
@@ -75,7 +75,7 @@ files:
75
75
  - lib/minting/mint/dsl/range.rb
76
76
  - lib/minting/mint/dsl/string.rb
77
77
  - lib/minting/mint/dsl/top_level.rb
78
- - lib/minting/mint/locale_backend.rb
78
+ - lib/minting/mint/i18n.rb
79
79
  - lib/minting/mint/mint.rb
80
80
  - lib/minting/mint/parser/parser.rb
81
81
  - lib/minting/mint/parser/separators.rb