minting 1.6.3 → 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 -113
- data/Rakefile +25 -3
- data/bin/bench_check +46 -0
- data/doc/Mint/Currency.html +1139 -0
- data/doc/Mint/CurrencyRegistry.html +511 -0
- data/doc/Mint/Money.html +3859 -0
- data/doc/Mint/RangeStepPatch.html +277 -0
- data/doc/Mint/UnknownCurrency.html +136 -0
- data/doc/Mint.html +911 -0
- data/doc/Minting.html +142 -0
- data/doc/_index.html +173 -0
- data/doc/class_list.html +54 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +206 -0
- data/doc/css/style.css +1089 -0
- data/doc/file.README.html +275 -0
- data/doc/file_list.html +59 -0
- data/doc/frames.html +22 -0
- data/doc/index.html +275 -0
- data/doc/js/app.js +801 -0
- data/doc/js/full_list.js +334 -0
- data/doc/js/jquery.js +4 -0
- data/doc/method_list.html +518 -0
- data/doc/top-level-namespace.html +151 -0
- data/lib/minting/{mint/currency → currency}/currency.rb +8 -0
- data/lib/minting/{mint/currency → currency}/currency_registry.rb +1 -1
- data/lib/minting/{mint/currency → currency}/world_currencies.rb +1 -1
- data/lib/minting/mint/aliases.rb +3 -0
- data/lib/minting/mint/dsl/numeric.rb +23 -0
- data/lib/minting/mint/dsl/range.rb +67 -0
- data/lib/minting/mint/dsl/string.rb +12 -0
- data/lib/minting/mint/{dsl.rb → dsl/top_level.rb} +3 -18
- data/lib/minting/mint/mint.rb +17 -3
- data/lib/minting/mint/{parser.rb → parser/parser.rb} +17 -29
- data/lib/minting/mint/parser/separators.rb +39 -0
- data/lib/minting/mint.rb +19 -8
- data/lib/minting/money/allocation/allocation.rb +25 -0
- data/lib/minting/money/allocation/split.rb +41 -0
- data/lib/minting/money/arithmetics/methods.rb +27 -0
- data/lib/minting/money/{arithmetics.rb → arithmetics/operators.rb} +5 -26
- data/lib/minting/money/clamp.rb +66 -0
- data/lib/minting/money/coercion.rb +18 -11
- 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} +5 -42
- data/lib/minting/money/money.rb +0 -58
- data/lib/minting/version.rb +1 -1
- data/minting.gemspec +5 -2
- metadata +42 -11
- data/lib/minting/money/allocation.rb +0 -59
|
@@ -24,7 +24,7 @@ module Mint
|
|
|
24
24
|
def currency_symbols
|
|
25
25
|
@currency_symbols ||= begin
|
|
26
26
|
currencies.values
|
|
27
|
-
.reject { |
|
|
27
|
+
.reject { |c| c.symbol.empty? }
|
|
28
28
|
.map { |currency| [currency.symbol, currency] }
|
|
29
29
|
.sort_by { |symbol, currency| [-symbol.length, -currency.priority] }
|
|
30
30
|
end.freeze
|
|
@@ -8,7 +8,7 @@ module Mint
|
|
|
8
8
|
# @api private
|
|
9
9
|
def self.world_currencies
|
|
10
10
|
@world_currencies ||= begin
|
|
11
|
-
path = File.join(File.expand_path('
|
|
11
|
+
path = File.join(File.expand_path('../data', __dir__), 'world-currencies.yaml')
|
|
12
12
|
|
|
13
13
|
YAML.load_file(path).to_h { |entry| [entry['code'], Currency.new(**entry.transform_keys(&:to_sym))] }
|
|
14
14
|
end.freeze
|
data/lib/minting/mint/aliases.rb
CHANGED
|
@@ -3,5 +3,8 @@
|
|
|
3
3
|
# Optional top‑level aliases for application use.
|
|
4
4
|
# Not required automatically.
|
|
5
5
|
|
|
6
|
+
# Alias for {Mint::Money} — enables `Money.new(...)` shorthand.
|
|
6
7
|
Money = Mint::Money
|
|
8
|
+
|
|
9
|
+
# Alias for {Mint::Currency} — enables `Currency.new(...)` shorthand.
|
|
7
10
|
Currency = Mint::Currency
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mint Numeric refinements
|
|
4
|
+
module Mint
|
|
5
|
+
refine Numeric do
|
|
6
|
+
# @return [Money] self interpreted as BRL
|
|
7
|
+
def reais = Mint.money(self, 'BRL')
|
|
8
|
+
|
|
9
|
+
# @return [Money] self interpreted as USD
|
|
10
|
+
def dollars = Mint.money(self, 'USD')
|
|
11
|
+
|
|
12
|
+
# @return [Money] self interpreted as EUR
|
|
13
|
+
def euros = Mint.money(self, 'EUR')
|
|
14
|
+
|
|
15
|
+
# @param currency [String, Symbol, Currency] target currency
|
|
16
|
+
# @return [Money] self interpreted as the given currency
|
|
17
|
+
def to_money(currency) = Mint.money(self, currency)
|
|
18
|
+
|
|
19
|
+
alias_method :dollar, :dollars
|
|
20
|
+
alias_method :euro, :euros
|
|
21
|
+
alias_method :mint, :to_money
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mint Range patch
|
|
4
|
+
# @api private
|
|
5
|
+
module Mint
|
|
6
|
+
if RUBY_VERSION < '4.0'
|
|
7
|
+
# Ruby < 4.0's Range#step calls rb_to_int on non-Numeric step arguments,
|
|
8
|
+
# which raises TypeError for Money objects. Ruby 4.0+ uses arithmetic
|
|
9
|
+
# iteration (+ / <=>) for non-numeric steps natively, so this patch is
|
|
10
|
+
# only needed on older Rubies.
|
|
11
|
+
module RangeStepPatch
|
|
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)
|
|
18
|
+
return super unless step_size.is_a?(Mint::Money)
|
|
19
|
+
|
|
20
|
+
raise TypeError, "can't iterate from NilClass" unless self.begin
|
|
21
|
+
raise ArgumentError, "step can't be 0" if step_size.zero?
|
|
22
|
+
|
|
23
|
+
if block
|
|
24
|
+
each_money_step(step_size, &block)
|
|
25
|
+
self
|
|
26
|
+
else
|
|
27
|
+
Enumerator.new { |yielder| each_money_step(step_size) { |value| yielder << value } }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
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)
|
|
42
|
+
current = self.begin
|
|
43
|
+
loop do
|
|
44
|
+
yield current
|
|
45
|
+
current += step
|
|
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?
|
|
55
|
+
|
|
56
|
+
loop do
|
|
57
|
+
break if asc ? current > last : current < last
|
|
58
|
+
break if exclude_end? && current == last
|
|
59
|
+
|
|
60
|
+
yield current
|
|
61
|
+
current += step
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
Range.prepend(Mint::RangeStepPatch)
|
|
66
|
+
end
|
|
67
|
+
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,24 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
# Mint refinements
|
|
4
4
|
module Mint
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def dollars = Mint.money(self, 'USD')
|
|
9
|
-
|
|
10
|
-
def euros = Mint.money(self, 'EUR')
|
|
11
|
-
|
|
12
|
-
def to_money(currency) = Mint.money(self, currency)
|
|
13
|
-
|
|
14
|
-
alias_method :dollar, :dollars
|
|
15
|
-
alias_method :euro, :euros
|
|
16
|
-
alias_method :mint, :to_money
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
refine String do
|
|
20
|
-
def to_money(currency) = Mint.money(to_r, currency)
|
|
21
|
-
end
|
|
22
|
-
|
|
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
|
|
23
8
|
def self.use_top_level_constants!
|
|
24
9
|
if !defined?(::Money) && !defined?(::Currency)
|
|
25
10
|
require 'minting/mint/aliases'
|
data/lib/minting/mint/mint.rb
CHANGED
|
@@ -21,14 +21,28 @@ module Mint
|
|
|
21
21
|
# @return [Currency, nil] the registered Currency instance or nil if not found
|
|
22
22
|
def self.currency(currency)
|
|
23
23
|
case currency
|
|
24
|
-
when
|
|
24
|
+
when NilClass then nil
|
|
25
25
|
when Currency then currency
|
|
26
26
|
when String then CurrencyRegistry.currencies[currency]
|
|
27
|
-
else
|
|
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
|
|
@@ -35,44 +35,32 @@ module Mint
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
37
|
# Extracts a numeric value from input that should only contain an amount.
|
|
38
|
+
# @private
|
|
38
39
|
def parse_amount(input)
|
|
40
|
+
accounting_negative = input.start_with?('(') && input.end_with?(')')
|
|
41
|
+
|
|
39
42
|
# Remove any charater that is not a digit, comma or period
|
|
40
43
|
numeric = input.scan(/[\d.,-]/).join
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Converts locale-specific decimal/thousand separators into a plain decimal string.
|
|
46
|
-
def normalize_separators(numeric)
|
|
47
|
-
case [numeric.count(','), numeric.count('.')]
|
|
48
|
-
in [0, 0] | [0, 1] then numeric # Nothing to normalize (e.g. "1500" or "34.21").
|
|
49
|
-
in [1, 0] then numeric.tr(',', '.') # Only one comma: decimal (e.g. 19,99 or 1,234).
|
|
50
|
-
in [c, p] if c > 1 && p > 1 # Both separators appear multiple times
|
|
51
|
-
raise ArgumentError, "could not distinguish decimal and thousand separators in '#{numeric}'"
|
|
52
|
-
in [c, p] if c > 0 && p > 0 # Commas and dots: the rightmost one is the decimal separator.
|
|
53
|
-
if numeric.rindex(',') > numeric.rindex('.')
|
|
54
|
-
numeric.delete('.').tr(',', '.')
|
|
55
|
-
else
|
|
56
|
-
numeric.delete(',')
|
|
57
|
-
end
|
|
58
|
-
else # Multiple of the same separator only (e.g. 1,234,567) — all are thousands.
|
|
59
|
-
numeric.delete(',.')
|
|
60
|
-
end
|
|
44
|
+
amount = Rational(normalize_separators(numeric))
|
|
45
|
+
accounting_negative ? -amount : amount
|
|
61
46
|
end
|
|
62
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
|
|
63
54
|
def parse_currency(input)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
when String
|
|
67
|
-
# Prefer an explicit ISO 4217 code (e.g. "USD 1,234.56") over symbol matching.
|
|
68
|
-
currency = Mint.currency(input[/\b([A-Z_]+)\b/, 1])
|
|
55
|
+
input.scan(/\b([A-Z_]+)\b/) do |(code)|
|
|
56
|
+
currency = Mint.currency(code)
|
|
69
57
|
return currency if currency
|
|
58
|
+
end
|
|
70
59
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return currency if input.include?(symbol)
|
|
74
|
-
end
|
|
60
|
+
CurrencyRegistry.currency_symbols.each do |symbol, currency|
|
|
61
|
+
return currency if input.include?(symbol)
|
|
75
62
|
end
|
|
63
|
+
|
|
76
64
|
raise ArgumentError, 'Currency could not be detected'
|
|
77
65
|
end
|
|
78
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,16 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'minting/
|
|
4
|
-
require 'minting/
|
|
5
|
-
require 'minting/
|
|
6
|
-
|
|
3
|
+
require 'minting/currency/currency'
|
|
4
|
+
require 'minting/currency/currency_registry'
|
|
5
|
+
require 'minting/currency/world_currencies'
|
|
6
|
+
|
|
7
|
+
require 'minting/mint/dsl/numeric'
|
|
8
|
+
require 'minting/mint/dsl/range'
|
|
9
|
+
require 'minting/mint/dsl/string'
|
|
10
|
+
require 'minting/mint/dsl/top_level'
|
|
11
|
+
|
|
7
12
|
require 'minting/mint/mint'
|
|
8
|
-
require 'minting/mint/parser'
|
|
9
|
-
require 'minting/
|
|
10
|
-
|
|
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'
|
|
11
21
|
require 'minting/money/coercion'
|
|
12
22
|
require 'minting/money/comparable'
|
|
13
23
|
require 'minting/money/constructors'
|
|
14
24
|
require 'minting/money/conversion'
|
|
15
|
-
require 'minting/money/formatting'
|
|
25
|
+
require 'minting/money/format/formatting'
|
|
26
|
+
require 'minting/money/format/to_s'
|
|
16
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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# Allocation and splitting
|
|
5
|
+
class Money
|
|
6
|
+
# Splits the monetary amount into a given quantity of equal parts.
|
|
7
|
+
# Disperses any fractional subunit rounding differences across the initial slots
|
|
8
|
+
# so that the sum is preserved.
|
|
9
|
+
#
|
|
10
|
+
# @param slices [Integer] the number of equal parts to divide the money into (must be > 0)
|
|
11
|
+
# @return [Array<Money>] the list of newly split Money objects
|
|
12
|
+
# @raise [ArgumentError] if quantity is not a positive integer
|
|
13
|
+
#
|
|
14
|
+
# @example Even split
|
|
15
|
+
# money = Mint.money(10.00, 'USD')
|
|
16
|
+
# money.split(3) #=> [[USD 3.34], [USD 3.33], [USD 3.33]]
|
|
17
|
+
def split(slices)
|
|
18
|
+
raise ArgumentError, 'Slices quantity must be an poitive integer' unless slices.positive? && slices.integer?
|
|
19
|
+
|
|
20
|
+
fraction = (amount / slices).round(currency.subunit)
|
|
21
|
+
allocate_left_over(amounts: Array.new(slices, fraction),
|
|
22
|
+
left_over: amount - (fraction * slices))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Distributes any leftover amount across the allocation slots by adjusting
|
|
28
|
+
# individual amounts by the currency's minimum unit, and converting to Money.
|
|
29
|
+
# Caution: amounts array is mutated by this method
|
|
30
|
+
# @private
|
|
31
|
+
def allocate_left_over(amounts:, left_over:)
|
|
32
|
+
if left_over.nonzero?
|
|
33
|
+
minimum = currency.minimum_amount
|
|
34
|
+
minimum = -minimum if left_over.negative?
|
|
35
|
+
slots_to_adjust = (left_over / minimum).to_i
|
|
36
|
+
(0...slots_to_adjust).each { |slot| amounts[slot] += minimum }
|
|
37
|
+
end
|
|
38
|
+
amounts.map! { |amount| Money.new(amount, currency) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -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
|
|
@@ -31,10 +10,10 @@ module Mint
|
|
|
31
10
|
# @raise [TypeError] if addition involves a different currency or incompatible types
|
|
32
11
|
def +(addend)
|
|
33
12
|
case addend
|
|
34
|
-
|
|
35
|
-
|
|
13
|
+
in 0 then self
|
|
14
|
+
in Money if same_currency?(addend) then mint(amount + addend.amount)
|
|
15
|
+
else raise TypeError, "#{addend} can't be added to #{self}"
|
|
36
16
|
end
|
|
37
|
-
raise TypeError, "#{addend} can't be added to #{self}"
|
|
38
17
|
end
|
|
39
18
|
|
|
40
19
|
# Performs subtraction with another {Money} instance or standard zero Numeric.
|
|
@@ -61,9 +40,9 @@ module Mint
|
|
|
61
40
|
# @return [Money] the multiplied Money instance
|
|
62
41
|
# @raise [TypeError] if multiplier is not Numeric or is a Money object
|
|
63
42
|
def *(multiplicand)
|
|
64
|
-
|
|
43
|
+
raise TypeError, "#{self} can't be multiplied by #{multiplicand}" unless multiplicand.is_a?(Numeric)
|
|
65
44
|
|
|
66
|
-
|
|
45
|
+
mint(amount * multiplicand)
|
|
67
46
|
end
|
|
68
47
|
|
|
69
48
|
# Performs division of the monetary value by a scalar Numeric or identical currency {Money}.
|
|
@@ -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
|
|
@@ -4,13 +4,13 @@ module Mint
|
|
|
4
4
|
# Implements the standard Ruby coercion protocol.
|
|
5
5
|
class Money
|
|
6
6
|
# Allows {Money} to interact seamlessly as the right-hand operand in Numeric arithmetic.
|
|
7
|
-
# This enables expressions like `5
|
|
7
|
+
# This enables expressions like `5 * money` where `5` is a Numeric and `money` is a Money object.
|
|
8
8
|
#
|
|
9
9
|
# @param other [Numeric] the left-hand operand to coerce
|
|
10
10
|
# @return [Array(CoercedNumber, Money)] coerced operand array
|
|
11
11
|
# @example
|
|
12
12
|
# price = Mint.money(10, 'USD')
|
|
13
|
-
# 5
|
|
13
|
+
# 5 * price #=> [USD 50.00] (via coercion)
|
|
14
14
|
def coerce(other)
|
|
15
15
|
[CoercedNumber.new(other), self]
|
|
16
16
|
end
|
|
@@ -20,34 +20,41 @@ module Mint
|
|
|
20
20
|
# @private
|
|
21
21
|
class CoercedNumber
|
|
22
22
|
# @private
|
|
23
|
-
def initialize(value)
|
|
24
|
-
@value = value
|
|
25
|
-
end
|
|
23
|
+
def initialize(value) = @value = value
|
|
26
24
|
|
|
27
25
|
# @private
|
|
26
|
+
# Adds a CoercedNumber to a Money object.
|
|
27
|
+
# Only zero is a valid additive identity (returns the Money unchanged).
|
|
28
28
|
def +(other)
|
|
29
|
-
|
|
29
|
+
raise_coercion_error(:+, other) unless @value.zero?
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
other
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# @private
|
|
35
|
+
# Subtracts a Money object from a CoercedNumber.
|
|
36
|
+
# Only zero is valid (returns the negated Money).
|
|
35
37
|
def -(other)
|
|
36
|
-
|
|
38
|
+
raise_coercion_error(:-, other) unless @value.zero?
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
-other
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
# @private
|
|
44
|
+
# Multiplies a Money object by the wrapped numeric value.
|
|
45
|
+
# This is the standard coercion path for `Numeric * Money`.
|
|
42
46
|
def *(other)
|
|
43
47
|
other.mint(@value * other.amount)
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
# @private
|
|
51
|
+
# Divides a CoercedNumber by a Money object.
|
|
52
|
+
# Not a meaningful operation (what currency is the result?).
|
|
47
53
|
def /(other)
|
|
48
54
|
raise_coercion_error(:/, other)
|
|
49
55
|
end
|
|
50
56
|
|
|
57
|
+
# @private
|
|
51
58
|
# Only zero is dimensionless and comparable to Money.
|
|
52
59
|
# e.g. 0 < price is meaningful; 0.5 < price is not (what currency is 0.5?).
|
|
53
60
|
def <=>(other)
|
|
@@ -58,9 +65,9 @@ module Mint
|
|
|
58
65
|
|
|
59
66
|
private
|
|
60
67
|
|
|
68
|
+
# Raises a TypeError with a descriptive message for unsupported coercions.
|
|
61
69
|
def raise_coercion_error(operation, operand)
|
|
62
|
-
raise TypeError,
|
|
63
|
-
"#{@value} #{operation} #{operand} : incompatible operands"
|
|
70
|
+
raise TypeError, "#{@value} #{operation} #{operand} : incompatible operands"
|
|
64
71
|
end
|
|
65
72
|
end
|
|
66
73
|
private_constant :CoercedNumber
|
|
@@ -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
|