minting 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +71 -118
- data/Rakefile +13 -1
- data/bin/bench_check +46 -0
- data/doc/Mint/Currency.html +178 -32
- data/doc/Mint/CurrencyRegistry.html +7 -7
- data/doc/Mint/Money.html +128 -125
- data/doc/Mint/RangeStepPatch.html +277 -0
- data/doc/Mint/UnknownCurrency.html +2 -2
- data/doc/Mint.html +47 -22
- data/doc/Minting.html +3 -3
- data/doc/_index.html +17 -2
- data/doc/class_list.html +1 -1
- data/doc/file.README.html +86 -89
- data/doc/index.html +86 -89
- data/doc/method_list.html +29 -21
- data/doc/top-level-namespace.html +13 -5
- data/lib/minting/{mint/currency → currency}/currency.rb +8 -0
- data/lib/minting/{mint/currency → currency}/world_currencies.rb +1 -1
- data/lib/minting/mint/aliases.rb +3 -0
- data/lib/minting/mint/dsl/{refinements.rb → numeric.rb} +6 -5
- data/lib/minting/mint/dsl/range.rb +31 -18
- data/lib/minting/mint/dsl/string.rb +12 -0
- data/lib/minting/mint/dsl/top_level.rb +3 -0
- data/lib/minting/mint/mint.rb +16 -2
- data/lib/minting/mint/parser/parser.rb +66 -0
- data/lib/minting/mint/parser/separators.rb +39 -0
- data/lib/minting/mint.rb +17 -8
- data/lib/minting/money/allocation/allocation.rb +25 -0
- data/lib/minting/money/{allocation.rb → allocation/split.rb} +1 -19
- data/lib/minting/money/arithmetics/methods.rb +27 -0
- data/lib/minting/money/{arithmetics.rb → arithmetics/operators.rb} +0 -21
- data/lib/minting/money/clamp.rb +66 -0
- data/lib/minting/money/coercion.rb +10 -0
- data/lib/minting/money/comparable.rb +6 -0
- data/lib/minting/money/constructors.rb +13 -3
- data/lib/minting/money/format/formatting.rb +44 -0
- data/lib/minting/money/{formatting.rb → format/to_s.rb} +0 -32
- data/lib/minting/money/money.rb +0 -58
- data/lib/minting/version.rb +1 -1
- metadata +19 -14
- data/lib/minting/mint/parser.rb +0 -85
- /data/lib/minting/{mint/currency → currency}/currency_registry.rb +0 -0
|
@@ -9,43 +9,56 @@ module Mint
|
|
|
9
9
|
# iteration (+ / <=>) for non-numeric steps natively, so this patch is
|
|
10
10
|
# only needed on older Rubies.
|
|
11
11
|
module RangeStepPatch
|
|
12
|
-
|
|
12
|
+
# Iterates over the range using a Money step value.
|
|
13
|
+
# Overrides Range#step to handle Mint::Money step sizes on Ruby < 4.0.
|
|
14
|
+
#
|
|
15
|
+
# @param step_size [Mint::Money, nil] step amount
|
|
16
|
+
# @return [self, Enumerator] self if block given, Enumerator otherwise
|
|
17
|
+
def step(step_size = nil, &block)
|
|
13
18
|
return super unless step_size.is_a?(Mint::Money)
|
|
14
19
|
|
|
15
20
|
raise TypeError, "can't iterate from NilClass" unless self.begin
|
|
16
21
|
raise ArgumentError, "step can't be 0" if step_size.zero?
|
|
17
22
|
|
|
18
|
-
if
|
|
19
|
-
each_money_step(step_size, &)
|
|
23
|
+
if block
|
|
24
|
+
each_money_step(step_size, &block)
|
|
20
25
|
self
|
|
21
26
|
else
|
|
22
|
-
Enumerator.new
|
|
23
|
-
each_money_step(step_size) { |v| yielder << v }
|
|
24
|
-
end
|
|
27
|
+
Enumerator.new { |yielder| each_money_step(step_size) { |value| yielder << value } }
|
|
25
28
|
end
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
private
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
# Dispatches to the appropriate iteration strategy based on range bounds.
|
|
34
|
+
# @private
|
|
35
|
+
def each_money_step(step, &)
|
|
36
|
+
self.end ? bounded_step(step, &) : unbounded_step(step, &)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Iterates an open-ended range (no upper bound) with a Money step.
|
|
40
|
+
# @private
|
|
41
|
+
def unbounded_step(step)
|
|
31
42
|
current = self.begin
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
loop do
|
|
36
|
-
yield current
|
|
37
|
-
current += step_amount
|
|
38
|
-
end
|
|
39
|
-
return
|
|
43
|
+
loop do
|
|
44
|
+
yield current
|
|
45
|
+
current += step
|
|
40
46
|
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Iterates a bounded range with a Money step, respecting direction and exclude_end?.
|
|
50
|
+
# @private
|
|
51
|
+
def bounded_step(step)
|
|
52
|
+
current = self.begin
|
|
53
|
+
last = self.end
|
|
54
|
+
asc = step.positive?
|
|
41
55
|
|
|
42
|
-
ascending = step_amount.positive?
|
|
43
56
|
loop do
|
|
44
|
-
break if
|
|
57
|
+
break if asc ? current > last : current < last
|
|
45
58
|
break if exclude_end? && current == last
|
|
46
59
|
|
|
47
60
|
yield current
|
|
48
|
-
current +=
|
|
61
|
+
current += step
|
|
49
62
|
end
|
|
50
63
|
end
|
|
51
64
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mint String refinement
|
|
4
|
+
module Mint
|
|
5
|
+
refine String do
|
|
6
|
+
# Parses self as a numeric string and creates a Money in the given currency.
|
|
7
|
+
#
|
|
8
|
+
# @param currency [String, Symbol, Currency] target currency
|
|
9
|
+
# @return [Money]
|
|
10
|
+
def to_money(currency) = Mint.money(to_r, currency)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
# Mint refinements
|
|
4
4
|
module Mint
|
|
5
|
+
# Registers top-level ::Money and ::Currency constants as aliases for Mint's classes.
|
|
6
|
+
#
|
|
7
|
+
# @raise [NameError] if ::Money or ::Currency are already defined and differ
|
|
5
8
|
def self.use_top_level_constants!
|
|
6
9
|
if !defined?(::Money) && !defined?(::Currency)
|
|
7
10
|
require 'minting/mint/aliases'
|
data/lib/minting/mint/mint.rb
CHANGED
|
@@ -24,11 +24,25 @@ module Mint
|
|
|
24
24
|
when NilClass then nil
|
|
25
25
|
when Currency then currency
|
|
26
26
|
when String then CurrencyRegistry.currencies[currency]
|
|
27
|
-
else raise ArgumentError, "currency must be [Currency]
|
|
27
|
+
else raise ArgumentError, "currency must be [Currency], [String] or nil (#{currency})"
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
#
|
|
31
|
+
# Returns a zero {Money} in the given currency, useful as a default value
|
|
32
|
+
# for discounts, totals, or placeholders.
|
|
33
|
+
#
|
|
34
|
+
# @param currency [String, Currency] a currency code or object
|
|
35
|
+
# @return [Money] a frozen zero-Money
|
|
36
|
+
# @raise [ArgumentError] if the currency is not registered
|
|
37
|
+
def self.zero(currency)
|
|
38
|
+
checked = Mint.currency(currency)
|
|
39
|
+
raise ArgumentError, "Invalid Currency: [#{currency}]" unless checked
|
|
40
|
+
|
|
41
|
+
@zeros ||= CurrencyRegistry.currencies.values.to_h { |currency| [currency, Mint::Money.send(:new, 0, currency)] }
|
|
42
|
+
@zeros[currency] ||= Money.send(:new, 0, currency)
|
|
43
|
+
@zeros[currency]
|
|
44
|
+
end
|
|
45
|
+
|
|
32
46
|
#
|
|
33
47
|
# @param code [String] the unique currency code
|
|
34
48
|
# @param subunit [Integer] the decimal subunit precision, defaults to 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mint Money parsing
|
|
4
|
+
module Mint
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
# Parses a human-readable money string into a {Money} object.
|
|
8
|
+
#
|
|
9
|
+
# @param input [String] Amount input, optionally including a currency symbol or code
|
|
10
|
+
# @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
|
|
11
|
+
# @return [Money]
|
|
12
|
+
# @raise [ArgumentError] when +input+ is invalid or currency cannot be determined
|
|
13
|
+
#
|
|
14
|
+
# @example With explicit currency
|
|
15
|
+
# Money.parse('19.99', 'USD') #=> [USD 19.99]
|
|
16
|
+
# Money.parse('1.234,56', 'EUR') #=> [EUR 1234.56]
|
|
17
|
+
#
|
|
18
|
+
# @example With symbol or code in the string
|
|
19
|
+
# Money.parse('$19.99') #=> [USD 19.99]
|
|
20
|
+
# Money.parse('19,99 €') #=> [EUR 19.99]
|
|
21
|
+
# Money.parse('USD 1,234.56') #=> [USD 1234.56]
|
|
22
|
+
def parse(input, currency = nil)
|
|
23
|
+
raise ArgumentError, 'input must be a String' unless input.is_a?(String)
|
|
24
|
+
|
|
25
|
+
input = input.strip
|
|
26
|
+
raise ArgumentError, 'input cannot be empty' if input.empty?
|
|
27
|
+
|
|
28
|
+
currency = Mint.currency(currency) || parse_currency(input)
|
|
29
|
+
raise ArgumentError, "Currency [#{currency}] not registered" unless currency
|
|
30
|
+
|
|
31
|
+
amount = currency.normalize_amount(parse_amount(input))
|
|
32
|
+
Mint::Money.new(amount, currency)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Extracts a numeric value from input that should only contain an amount.
|
|
38
|
+
# @private
|
|
39
|
+
def parse_amount(input)
|
|
40
|
+
accounting_negative = input.start_with?('(') && input.end_with?(')')
|
|
41
|
+
|
|
42
|
+
# Remove any charater that is not a digit, comma or period
|
|
43
|
+
numeric = input.scan(/[\d.,-]/).join
|
|
44
|
+
amount = Rational(normalize_separators(numeric))
|
|
45
|
+
accounting_negative ? -amount : amount
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Extracts currency from a string by matching ISO code or symbol.
|
|
49
|
+
#
|
|
50
|
+
# Scans all uppercase words and returns the first registered code, falling
|
|
51
|
+
# back to symbol matching. This correctly handles inputs like
|
|
52
|
+
# "MAX 10.00 USD" where the first uppercase word isn't a currency code.
|
|
53
|
+
# @private
|
|
54
|
+
def parse_currency(input)
|
|
55
|
+
input.scan(/\b([A-Z_]+)\b/) do |(code)|
|
|
56
|
+
currency = Mint.currency(code)
|
|
57
|
+
return currency if currency
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
CurrencyRegistry.currency_symbols.each do |symbol, currency|
|
|
61
|
+
return currency if input.include?(symbol)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, 'Currency could not be detected'
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mint Money parsing
|
|
4
|
+
module Mint
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Classifies the separator pattern in a numeric string.
|
|
10
|
+
# @private
|
|
11
|
+
def classify_separators(numeric)
|
|
12
|
+
case [numeric.count('.'), numeric.count(',')]
|
|
13
|
+
in [0, 1] if numeric[-4] == ',' then :thousands_comma # Comma is a thousand separator
|
|
14
|
+
in [0, 1] then :decimal_comma # Only one comma: decimal (e.g. 19,99 or 1,4 or 1,2345).
|
|
15
|
+
in [0, 0] | [1, 0] then :decimal_period # e.g. "1500" or "34.21".
|
|
16
|
+
in [p, c] if p > 1 && c > 1 then :ambiguous # Both separators appear multiple times
|
|
17
|
+
in [p, c] if p > 0 && c > 0 then :mixed # Commas and dots: the rightmost one is the decimal
|
|
18
|
+
else :thousands # Multiple of the same separator only (e.g. 1,234,567)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Converts locale-specific decimal/thousand separators into a plain decimal string.
|
|
23
|
+
# @private
|
|
24
|
+
def normalize_separators(numeric)
|
|
25
|
+
case classify_separators(numeric)
|
|
26
|
+
when :decimal_period then numeric # Nothing to normalize (e.g. "1500" or "34.21").
|
|
27
|
+
when :decimal_comma then numeric.tr(',', '.') # Only one comma: decimal (e.g. 19,99 or 1,234).
|
|
28
|
+
when :thousands_comma then numeric.delete(',')
|
|
29
|
+
when :thousands then numeric.delete('.,')
|
|
30
|
+
when :ambiguous then raise ArgumentError, "could not distinguish decimal and thousand separators in '#{numeric}'"
|
|
31
|
+
when :mixed # Commas and dots: the rightmost one is the decimal separator.
|
|
32
|
+
if numeric.rindex(',') > numeric.rindex('.')
|
|
33
|
+
numeric.delete('.').tr(',', '.')
|
|
34
|
+
else
|
|
35
|
+
numeric.delete(',')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/minting/mint.rb
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'minting/
|
|
4
|
-
require 'minting/
|
|
5
|
-
require 'minting/
|
|
3
|
+
require 'minting/currency/currency'
|
|
4
|
+
require 'minting/currency/currency_registry'
|
|
5
|
+
require 'minting/currency/world_currencies'
|
|
6
|
+
|
|
7
|
+
require 'minting/mint/dsl/numeric'
|
|
6
8
|
require 'minting/mint/dsl/range'
|
|
7
|
-
require 'minting/mint/dsl/
|
|
9
|
+
require 'minting/mint/dsl/string'
|
|
8
10
|
require 'minting/mint/dsl/top_level'
|
|
11
|
+
|
|
9
12
|
require 'minting/mint/mint'
|
|
10
|
-
require 'minting/mint/parser'
|
|
11
|
-
require 'minting/
|
|
12
|
-
|
|
13
|
+
require 'minting/mint/parser/parser'
|
|
14
|
+
require 'minting/mint/parser/separators'
|
|
15
|
+
|
|
16
|
+
require 'minting/money/allocation/allocation'
|
|
17
|
+
require 'minting/money/allocation/split'
|
|
18
|
+
require 'minting/money/arithmetics/methods'
|
|
19
|
+
require 'minting/money/arithmetics/operators'
|
|
20
|
+
require 'minting/money/clamp'
|
|
13
21
|
require 'minting/money/coercion'
|
|
14
22
|
require 'minting/money/comparable'
|
|
15
23
|
require 'minting/money/constructors'
|
|
16
24
|
require 'minting/money/conversion'
|
|
17
|
-
require 'minting/money/formatting'
|
|
25
|
+
require 'minting/money/format/formatting'
|
|
26
|
+
require 'minting/money/format/to_s'
|
|
18
27
|
require 'minting/money/money'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# Allocation and splitting
|
|
5
|
+
class Money
|
|
6
|
+
# Proportionally allocates the monetary amount among a list of ratios.
|
|
7
|
+
# Disperses any subunit rounding amounts across the initial slots
|
|
8
|
+
# @param proportions [Array<Numeric>] a list of numeric proportions/ratios to allocate by
|
|
9
|
+
# @return [Array<Money>] the list of newly allocated Money objects
|
|
10
|
+
# @raise [ArgumentError] if the proportions list is empty or sums to zero
|
|
11
|
+
#
|
|
12
|
+
# @example Proportional allocation
|
|
13
|
+
# money = Mint.money(10.00, 'USD')
|
|
14
|
+
# money.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
|
|
15
|
+
def allocate(proportions)
|
|
16
|
+
whole = proportions.sum.to_r
|
|
17
|
+
raise ArgumentError, 'Need at least 1 proportion element' if proportions.empty?
|
|
18
|
+
raise ArgumentError, 'Proportions total must not be zero' if whole.zero?
|
|
19
|
+
|
|
20
|
+
subunit = currency.subunit
|
|
21
|
+
amounts = proportions.map { |rate| Rational(amount * rate, whole).round(subunit) }
|
|
22
|
+
allocate_left_over(amounts: amounts, left_over: amount - amounts.sum)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -3,25 +3,6 @@
|
|
|
3
3
|
module Mint
|
|
4
4
|
# Allocation and splitting
|
|
5
5
|
class Money
|
|
6
|
-
# Proportionally allocates the monetary amount among a list of ratios.
|
|
7
|
-
# Disperses any subunit rounding amounts across the initial slots
|
|
8
|
-
# @param proportions [Array<Numeric>] a list of numeric proportions/ratios to allocate by
|
|
9
|
-
# @return [Array<Money>] the list of newly allocated Money objects
|
|
10
|
-
# @raise [ArgumentError] if the proportions list is empty or sums to zero
|
|
11
|
-
#
|
|
12
|
-
# @example Proportional allocation
|
|
13
|
-
# money = Mint.money(10.00, 'USD')
|
|
14
|
-
# money.allocate([1, 2, 3]) #=> [[USD 1.67], [USD 3.33], [USD 5.00]]
|
|
15
|
-
def allocate(proportions)
|
|
16
|
-
whole = proportions.sum.to_r
|
|
17
|
-
raise ArgumentError, 'Need at least 1 proportion element' if proportions.empty?
|
|
18
|
-
raise ArgumentError, 'Proportions total must not be zero' if whole.zero?
|
|
19
|
-
|
|
20
|
-
subunit = currency.subunit
|
|
21
|
-
amounts = proportions.map { |rate| Rational(amount * rate, whole).round(subunit) }
|
|
22
|
-
allocate_left_over(amounts: amounts, left_over: amount - amounts.sum)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
6
|
# Splits the monetary amount into a given quantity of equal parts.
|
|
26
7
|
# Disperses any fractional subunit rounding differences across the initial slots
|
|
27
8
|
# so that the sum is preserved.
|
|
@@ -46,6 +27,7 @@ module Mint
|
|
|
46
27
|
# Distributes any leftover amount across the allocation slots by adjusting
|
|
47
28
|
# individual amounts by the currency's minimum unit, and converting to Money.
|
|
48
29
|
# Caution: amounts array is mutated by this method
|
|
30
|
+
# @private
|
|
49
31
|
def allocate_left_over(amounts:, left_over:)
|
|
50
32
|
if left_over.nonzero?
|
|
51
33
|
minimum = currency.minimum_amount
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# Money Arithmetics
|
|
5
|
+
class Money
|
|
6
|
+
# Returns the absolute value of the monetary amount as a new {Money} instance.
|
|
7
|
+
#
|
|
8
|
+
# @return [Money] the absolute value
|
|
9
|
+
def abs = mint(amount.abs)
|
|
10
|
+
|
|
11
|
+
# Returns true if the monetary amount is less than zero.
|
|
12
|
+
#
|
|
13
|
+
# @return [Boolean] true if negative, false otherwise
|
|
14
|
+
def negative? = amount.negative?
|
|
15
|
+
|
|
16
|
+
# Returns true if the monetary amount is greater than zero.
|
|
17
|
+
#
|
|
18
|
+
# @return [Boolean] true if positive, false otherwise
|
|
19
|
+
def positive? = amount.positive?
|
|
20
|
+
|
|
21
|
+
# Returns the successor of the Money instance by adding the minimum possible subunit amount.
|
|
22
|
+
# Enables standard ranges and stepping (e.g. `1.dollar..10.dollars`).
|
|
23
|
+
#
|
|
24
|
+
# @return [Money] successor Money instance
|
|
25
|
+
def succ = mint(amount + currency.minimum_amount)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -3,27 +3,6 @@
|
|
|
3
3
|
module Mint
|
|
4
4
|
# Money Arithmetics
|
|
5
5
|
class Money
|
|
6
|
-
# Returns the absolute value of the monetary amount as a new {Money} instance.
|
|
7
|
-
#
|
|
8
|
-
# @return [Money] the absolute value
|
|
9
|
-
def abs = mint(amount.abs)
|
|
10
|
-
|
|
11
|
-
# Returns true if the monetary amount is less than zero.
|
|
12
|
-
#
|
|
13
|
-
# @return [Boolean] true if negative, false otherwise
|
|
14
|
-
def negative? = amount.negative?
|
|
15
|
-
|
|
16
|
-
# Returns true if the monetary amount is greater than zero.
|
|
17
|
-
#
|
|
18
|
-
# @return [Boolean] true if positive, false otherwise
|
|
19
|
-
def positive? = amount.positive?
|
|
20
|
-
|
|
21
|
-
# Returns the successor of the Money instance by adding the minimum possible subunit amount.
|
|
22
|
-
# Enables standard ranges and stepping (e.g. `1.dollar..10.dollars`).
|
|
23
|
-
#
|
|
24
|
-
# @return [Money] successor Money instance
|
|
25
|
-
def succ = mint(amount + currency.minimum_amount)
|
|
26
|
-
|
|
27
6
|
# Performs addition with another {Money} instance or standard zero Numeric.
|
|
28
7
|
#
|
|
29
8
|
# @param addend [Money, Numeric] the value to add
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# Money clamp
|
|
5
|
+
class Money
|
|
6
|
+
# Constrains +self+ to the inclusive range [+min+, +max+].
|
|
7
|
+
#
|
|
8
|
+
# Bounds may be:
|
|
9
|
+
# - nil meaning no boundary
|
|
10
|
+
# - same-currency {Money} or Range
|
|
11
|
+
# - Numeric amount, or Range
|
|
12
|
+
#
|
|
13
|
+
# Numeric is interpreted as an amount in +self+'s currency, so the common
|
|
14
|
+
# pricing idiom +price.clamp(0, 100)+ reads as "0 to 100 in the same
|
|
15
|
+
# currency as +price+".
|
|
16
|
+
#
|
|
17
|
+
# When +self+ is already in range the receiver is returned (no new object
|
|
18
|
+
# allocated). When out of range, the nearest bound is returned as a new
|
|
19
|
+
# frozen {Money} in +self+'s currency.
|
|
20
|
+
#
|
|
21
|
+
# @param min_or_range [Money, Numeric, Range, nil] lower bound (inclusive), or range
|
|
22
|
+
# @param max [Money, Numeric, nil] upper bound (inclusive)
|
|
23
|
+
# @return [Money] +self+ if in range, otherwise the nearer bound
|
|
24
|
+
# @raise [ArgumentError] if +min+ or +max+ is not a Money, Numeric or nil; if
|
|
25
|
+
# a Money operand has a different currency; if +min+ > +max+;
|
|
26
|
+
# if min is a Range, and max is not nil
|
|
27
|
+
#
|
|
28
|
+
# @example In range
|
|
29
|
+
# Mint.money(5, 'USD').clamp(0, 10) #=> [USD 5.00] (returns self)
|
|
30
|
+
#
|
|
31
|
+
# @example Out of range, with Numeric bounds
|
|
32
|
+
# Mint.money(50, 'USD').clamp(0, 10) #=> [USD 10.00]
|
|
33
|
+
#
|
|
34
|
+
# @example Out of range, with Money bounds
|
|
35
|
+
# loss = Mint.money(-5, 'USD')
|
|
36
|
+
# floor = Mint.money(0, 'USD')
|
|
37
|
+
# ceil = Mint.money(10, 'USD')
|
|
38
|
+
# loss.clamp(floor, ceil) #=> [USD 0.00]
|
|
39
|
+
#
|
|
40
|
+
# @example Subunit-0 currency (JPY)
|
|
41
|
+
# Mint.money(500, 'JPY').clamp(0, 100) #=> [JPY 100]
|
|
42
|
+
def clamp(min_or_range, max = nil)
|
|
43
|
+
if min_or_range.is_a?(Range)
|
|
44
|
+
raise(ArgumentError, "Either amount range alone or two amounts accepted: #{max}") if max
|
|
45
|
+
|
|
46
|
+
min, max = min_or_range.minmax
|
|
47
|
+
else
|
|
48
|
+
min = min_or_range
|
|
49
|
+
end
|
|
50
|
+
mint(amount.clamp(normalize_boundary(min), normalize_boundary(max)))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Converts a clamp boundary to a numeric amount.
|
|
56
|
+
# @private
|
|
57
|
+
def normalize_boundary(boundary)
|
|
58
|
+
case boundary
|
|
59
|
+
in NilClass | Numeric then boundary
|
|
60
|
+
in Money if same_currency?(boundary) then boundary.amount
|
|
61
|
+
in Money then raise ArgumentError, "oundary currency must be: #{currency_code}"
|
|
62
|
+
else raise ArgumentError, "Boundary must be Numeric or Money #{boundary}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -23,6 +23,8 @@ module Mint
|
|
|
23
23
|
def initialize(value) = @value = value
|
|
24
24
|
|
|
25
25
|
# @private
|
|
26
|
+
# Adds a CoercedNumber to a Money object.
|
|
27
|
+
# Only zero is a valid additive identity (returns the Money unchanged).
|
|
26
28
|
def +(other)
|
|
27
29
|
raise_coercion_error(:+, other) unless @value.zero?
|
|
28
30
|
|
|
@@ -30,6 +32,8 @@ module Mint
|
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# @private
|
|
35
|
+
# Subtracts a Money object from a CoercedNumber.
|
|
36
|
+
# Only zero is valid (returns the negated Money).
|
|
33
37
|
def -(other)
|
|
34
38
|
raise_coercion_error(:-, other) unless @value.zero?
|
|
35
39
|
|
|
@@ -37,15 +41,20 @@ module Mint
|
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
# @private
|
|
44
|
+
# Multiplies a Money object by the wrapped numeric value.
|
|
45
|
+
# This is the standard coercion path for `Numeric * Money`.
|
|
40
46
|
def *(other)
|
|
41
47
|
other.mint(@value * other.amount)
|
|
42
48
|
end
|
|
43
49
|
|
|
44
50
|
# @private
|
|
51
|
+
# Divides a CoercedNumber by a Money object.
|
|
52
|
+
# Not a meaningful operation (what currency is the result?).
|
|
45
53
|
def /(other)
|
|
46
54
|
raise_coercion_error(:/, other)
|
|
47
55
|
end
|
|
48
56
|
|
|
57
|
+
# @private
|
|
49
58
|
# Only zero is dimensionless and comparable to Money.
|
|
50
59
|
# e.g. 0 < price is meaningful; 0.5 < price is not (what currency is 0.5?).
|
|
51
60
|
def <=>(other)
|
|
@@ -56,6 +65,7 @@ module Mint
|
|
|
56
65
|
|
|
57
66
|
private
|
|
58
67
|
|
|
68
|
+
# Raises a TypeError with a descriptive message for unsupported coercions.
|
|
59
69
|
def raise_coercion_error(operation, operand)
|
|
60
70
|
raise TypeError, "#{@value} #{operation} #{operand} : incompatible operands"
|
|
61
71
|
end
|
|
@@ -14,6 +14,10 @@ module Mint
|
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Strict equality — both amount and currency must match exactly.
|
|
18
|
+
# Unlike ==, does not treat zero as equivalent across currencies.
|
|
19
|
+
#
|
|
20
|
+
# @return [Boolean]
|
|
17
21
|
def eql?(other)
|
|
18
22
|
other.is_a?(Mint::Money) &&
|
|
19
23
|
amount == other.amount &&
|
|
@@ -37,8 +41,10 @@ module Mint
|
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
|
|
44
|
+
# @return [self, nil] self if amount is non-zero, nil otherwise
|
|
40
45
|
def nonzero? = amount.nonzero?
|
|
41
46
|
|
|
47
|
+
# @return [Boolean] true if amount is zero
|
|
42
48
|
def zero? = amount.zero?
|
|
43
49
|
end
|
|
44
50
|
end
|
|
@@ -13,7 +13,9 @@ module Mint
|
|
|
13
13
|
checked_currency = Mint.currency(currency)
|
|
14
14
|
raise ArgumentError, "Currency not found (#{currency})" unless checked_currency
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
amount = checked_currency.normalize_amount(amount)
|
|
17
|
+
|
|
18
|
+
amount.zero? ? Mint.zero(checked_currency) : new(amount, checked_currency)
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
# Builds a Money from a fractional (smallest-unit) Integer amount.
|
|
@@ -41,7 +43,8 @@ module Mint
|
|
|
41
43
|
raise ArgumentError, "Currency not found (#{currency})" unless checked_currency
|
|
42
44
|
|
|
43
45
|
amount = Rational(fractional, checked_currency.fractional_multiplier)
|
|
44
|
-
|
|
46
|
+
|
|
47
|
+
amount.zero? ? Mint.zero(checked_currency) : new(amount, checked_currency)
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
# Returns a new Money object with the specified amount, or self if unchanged.
|
|
@@ -56,7 +59,14 @@ module Mint
|
|
|
56
59
|
# price.mint(10.00) #=> [USD 10.00] (returns self)
|
|
57
60
|
def mint(new_amount)
|
|
58
61
|
new_amount = currency.normalize_amount(new_amount)
|
|
59
|
-
|
|
62
|
+
|
|
63
|
+
if new_amount == amount
|
|
64
|
+
self
|
|
65
|
+
elsif new_amount.zero?
|
|
66
|
+
Mint.zero(currency)
|
|
67
|
+
else
|
|
68
|
+
Money.new(new_amount, currency)
|
|
69
|
+
end
|
|
60
70
|
end
|
|
61
71
|
|
|
62
72
|
private
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# Formatting functionality for Money objects
|
|
5
|
+
class Money
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# Selects the appropriate format template and value based on the amount's sign.
|
|
9
|
+
# @private
|
|
10
|
+
def select_format(format)
|
|
11
|
+
negative_format = format[:negative]
|
|
12
|
+
zero_format = format[:zero]
|
|
13
|
+
|
|
14
|
+
if amount.negative? && negative_format
|
|
15
|
+
[negative_format, -amount]
|
|
16
|
+
elsif amount.zero? && zero_format
|
|
17
|
+
[zero_format, amount]
|
|
18
|
+
else
|
|
19
|
+
[format[:positive], amount]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Validates that format hash contains only known keys.
|
|
24
|
+
# @private
|
|
25
|
+
def validate_format_hash(format)
|
|
26
|
+
unknown = format.keys - %i[positive negative zero]
|
|
27
|
+
|
|
28
|
+
raise ArgumentError, "Unknown format parameter(s): #{unknown.inspect}. " unless unknown.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Applies a format template to produce a formatted string representation.
|
|
32
|
+
# @private
|
|
33
|
+
def format_amount(format)
|
|
34
|
+
format, value = select_format(format)
|
|
35
|
+
format ||= '%<symbol>s%<amount>f'
|
|
36
|
+
# Automatically adjust decimal places based on currency subunit if missing
|
|
37
|
+
format = format.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{currency.subunit}f")
|
|
38
|
+
|
|
39
|
+
refs = format.scan(/%<(\w+)>/).flatten.map(&:to_sym)
|
|
40
|
+
all_args = { amount: value, currency: currency_code, symbol: currency.symbol }
|
|
41
|
+
Kernel.format(format, **all_args.slice(*refs))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -63,37 +63,5 @@ module Mint
|
|
|
63
63
|
|
|
64
64
|
width ? formatted.rjust(width) : formatted
|
|
65
65
|
end
|
|
66
|
-
|
|
67
|
-
private
|
|
68
|
-
|
|
69
|
-
def select_format(format)
|
|
70
|
-
negative_format = format[:negative]
|
|
71
|
-
zero_format = format[:zero]
|
|
72
|
-
|
|
73
|
-
if amount.negative? && negative_format
|
|
74
|
-
[negative_format, -amount]
|
|
75
|
-
elsif amount.zero? && zero_format
|
|
76
|
-
[zero_format, amount]
|
|
77
|
-
else
|
|
78
|
-
[format[:positive], amount]
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def validate_format_hash(format)
|
|
83
|
-
unknown = format.keys - %i[positive negative zero]
|
|
84
|
-
|
|
85
|
-
raise ArgumentError, "Unknown format parameter(s): #{unknown.inspect}. " unless unknown.empty?
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def format_amount(format)
|
|
89
|
-
format, value = select_format(format)
|
|
90
|
-
format ||= '%<symbol>s%<amount>f'
|
|
91
|
-
# Automatically adjust decimal places based on currency subunit if missing
|
|
92
|
-
format = format.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{currency.subunit}f")
|
|
93
|
-
|
|
94
|
-
refs = format.scan(/%<(\w+)>/).flatten.map(&:to_sym)
|
|
95
|
-
all_args = { amount: value, currency: currency_code, symbol: currency.symbol }
|
|
96
|
-
Kernel.format(format, **all_args.slice(*refs))
|
|
97
|
-
end
|
|
98
66
|
end
|
|
99
67
|
end
|