minting 1.7.0 → 1.7.3
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 +80 -112
- data/Rakefile +13 -1
- data/bin/bench_check +46 -0
- data/doc/Mint/Currency.html +446 -46
- data/doc/Mint/CurrencyRegistry.html +7 -7
- data/doc/Mint/Money.html +203 -177
- data/doc/Mint/RangeStepPatch.html +277 -0
- data/doc/Mint/Registry.html +842 -0
- data/doc/Mint/UnknownCurrency.html +2 -2
- data/doc/Mint.html +385 -66
- data/doc/Minting.html +3 -3
- data/doc/_index.html +24 -9
- data/doc/agents/api_review-2026-06-15.md +342 -0
- data/doc/class_list.html +1 -1
- data/doc/file.README.html +97 -89
- data/doc/index.html +97 -89
- data/doc/method_list.html +97 -25
- data/doc/top-level-namespace.html +13 -5
- data/lib/minting/currency/currency.rb +75 -0
- 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/locale_backend.rb +29 -0
- data/lib/minting/mint/mint.rb +28 -12
- data/lib/minting/mint/parser/parser.rb +62 -0
- data/lib/minting/mint/parser/separators.rb +39 -0
- data/lib/minting/mint/registry/registration.rb +33 -0
- data/lib/minting/mint/registry/registry.rb +38 -0
- data/lib/minting/mint/registry/symbols.rb +49 -0
- data/lib/minting/mint/registry/zeros.rb +18 -0
- data/lib/minting/mint.rb +13 -16
- 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 +14 -9
- data/lib/minting/money/format/formatting.rb +60 -0
- data/lib/minting/money/{formatting.rb → format/to_s.rb} +13 -36
- data/lib/minting/money/money.rb +12 -58
- data/lib/minting/version.rb +1 -1
- metadata +29 -19
- data/lib/minting/mint/currency/currency.rb +0 -36
- data/lib/minting/mint/currency/currency_registry.rb +0 -67
- data/lib/minting/mint/currency/world_currencies.rb +0 -16
- data/lib/minting/mint/parser.rb +0 -85
- /data/doc/agents/{AGENTS.md → expired/AGENTS.md} +0 -0
- /data/doc/agents/{copilot-instructions.md → expired/copilot-instructions.md} +0 -0
- /data/doc/agents/{gemini_gem_evaluation.md → expired/gemini_gem_evaluation.md} +0 -0
- /data/doc/agents/{recommendations.md → expired/recommendations.md} +0 -0
- /data/doc/agents/{rubocop-issues.md → expired/rubocop-issues.md} +0 -0
|
@@ -102,8 +102,7 @@
|
|
|
102
102
|
<dt id="Money-constant" class="">Money =
|
|
103
103
|
<div class="docstring">
|
|
104
104
|
<div class="discussion">
|
|
105
|
-
<p>
|
|
106
|
-
Not required automatically.</p>
|
|
105
|
+
<p>Alias for <span class='object_link'><a href="Mint/Money.html" title="Mint::Money (class)">Mint::Money</a></span> — enables <code>Money.new(...)</code> shorthand.</p>
|
|
107
106
|
|
|
108
107
|
</div>
|
|
109
108
|
</div>
|
|
@@ -115,7 +114,16 @@ Not required automatically.</p>
|
|
|
115
114
|
<dd><pre class="code"><span class='const'><span class='object_link'><a href="Mint.html" title="Mint (module)">Mint</a></span></span><span class='op'>::</span><span class='const'><span class='object_link'><a href="Mint/Money.html" title="Mint::Money (class)">Money</a></span></span></pre></dd>
|
|
116
115
|
|
|
117
116
|
<dt id="Currency-constant" class="">Currency =
|
|
118
|
-
|
|
117
|
+
<div class="docstring">
|
|
118
|
+
<div class="discussion">
|
|
119
|
+
<p>Alias for <span class='object_link'><a href="Mint/Currency.html" title="Mint::Currency (class)">Mint::Currency</a></span> — enables <code>Currency.new(...)</code> shorthand.</p>
|
|
120
|
+
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="tags">
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
</div>
|
|
119
127
|
</dt>
|
|
120
128
|
<dd><pre class="code"><span class='const'><span class='object_link'><a href="Mint.html" title="Mint (module)">Mint</a></span></span><span class='op'>::</span><span class='const'><span class='object_link'><a href="Mint/Currency.html" title="Mint::Currency (class)">Currency</a></span></span></pre></dd>
|
|
121
129
|
|
|
@@ -133,9 +141,9 @@ Not required automatically.</p>
|
|
|
133
141
|
</div>
|
|
134
142
|
|
|
135
143
|
<div id="footer">
|
|
136
|
-
Generated on
|
|
144
|
+
Generated on Mon Jun 15 19:57:57 2026 by
|
|
137
145
|
<a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
|
|
138
|
-
0.9.44 (ruby-4.0.
|
|
146
|
+
0.9.44 (ruby-4.0.5).
|
|
139
147
|
</div>
|
|
140
148
|
|
|
141
149
|
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :nodoc:
|
|
4
|
+
module Mint
|
|
5
|
+
# Represents a specific currency unit, identified by ISO 4217 alphabetic code.
|
|
6
|
+
# Currency objects are immutable and define the properties of a monetary unit
|
|
7
|
+
# including its subunit precision, display symbol, and formatting rules.
|
|
8
|
+
#
|
|
9
|
+
# @see https://www.iso.org/iso-4217-currency-codes.html
|
|
10
|
+
# @attr_reader code [String] ISO 4217 currency code (e.g., "USD", "EUR")
|
|
11
|
+
# @attr_reader subunit [Integer] Number of decimal places (0 for JPY, 2 for USD, 3 for IQD)
|
|
12
|
+
# @attr_reader symbol [String] Display symbol (e.g., "$", "€", "R$")
|
|
13
|
+
# @attr_reader priority [Integer] Parser precedence for symbol detection
|
|
14
|
+
# @attr_reader country [String, nil] Associated country code
|
|
15
|
+
# @attr_reader name [String, nil] Currency name
|
|
16
|
+
# @attr_reader fractional_multiplier [Integer] 10^subunit, used for fractional conversions
|
|
17
|
+
# @attr_reader minimum_amount [Rational] Smallest representable amount (1/fractional_multiplier)
|
|
18
|
+
Currency = Data.define(:code, :subunit, :symbol, :priority, :country, :name,
|
|
19
|
+
:fractional_multiplier) do
|
|
20
|
+
# @param code [String] ISO 4217 currency code
|
|
21
|
+
# @param symbol [String] Display symbol
|
|
22
|
+
# @param subunit [Integer] Number of decimal places (default 0)
|
|
23
|
+
# @param priority [Integer] Parser precedence for symbol detection (default 0)
|
|
24
|
+
# @param country [String, nil] Associated country code (default nil)
|
|
25
|
+
# @param name [String, nil] Currency name (default nil)
|
|
26
|
+
def initialize(code:, symbol:, subunit: 0, priority: 0, country: nil, name: nil)
|
|
27
|
+
subunit = subunit.to_i
|
|
28
|
+
priority = priority.to_i
|
|
29
|
+
fractional_multiplier = 10**subunit
|
|
30
|
+
super(code:, subunit:, symbol:, priority:, country:, name:,
|
|
31
|
+
fractional_multiplier:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String] debug representation
|
|
35
|
+
def inspect = "<Currency:(#{code} #{symbol} #{subunit} #{name})>"
|
|
36
|
+
|
|
37
|
+
# @return [Rational] smallest representable amount (1/fractional_multiplier)
|
|
38
|
+
def minimum_amount = Rational(1, fractional_multiplier)
|
|
39
|
+
|
|
40
|
+
# Normalizes numeric amounts for this currency
|
|
41
|
+
# 1. Converts to Rational
|
|
42
|
+
# 2. Rounds to respect currency subunit
|
|
43
|
+
def normalize_amount(amount) = amount.to_r.round(subunit)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolves an object into a {Currency}, returning +nil+ when it can't.
|
|
47
|
+
#
|
|
48
|
+
# Accepts +nil+, +String+, {Currency}, or {Money}.
|
|
49
|
+
# Passing a {Money} extracts its currency
|
|
50
|
+
#
|
|
51
|
+
# @param object [String, Currency, Money, nil] a currency code, object, or +nil+
|
|
52
|
+
# @return [Currency, nil] the resolved Currency, or +nil+ if +object+ is +nil+
|
|
53
|
+
# or the code is not registered
|
|
54
|
+
# @raise [ArgumentError] if +object+ is an unsupported type (e.g. +Integer+)
|
|
55
|
+
def Currency.resolve(object)
|
|
56
|
+
case object
|
|
57
|
+
when NilClass then nil
|
|
58
|
+
when Currency then object
|
|
59
|
+
when Money then object.currency
|
|
60
|
+
when String then Mint.currency_for_code object
|
|
61
|
+
else raise ArgumentError, "currency must be [Currency], [Money], [String] or nil (#{object})"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Resolves an object into a {Currency}, raising on failure.
|
|
66
|
+
#
|
|
67
|
+
# Like {.resolve} but raises when the result would be +nil+.
|
|
68
|
+
#
|
|
69
|
+
# @param object [String, Currency, Money, nil] a currency code, object, or +nil+
|
|
70
|
+
# @return [Currency] the resolved Currency
|
|
71
|
+
# @raise [ArgumentError] if +object+ cannot be resolved into a registered currency
|
|
72
|
+
def Currency.resolve!(object)
|
|
73
|
+
resolve(object) or raise ArgumentError, "Could not resolve (#{object}) into a currency"
|
|
74
|
+
end
|
|
75
|
+
end
|
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
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Mint refinements
|
|
3
|
+
# Mint Numeric refinements
|
|
4
4
|
module Mint
|
|
5
5
|
refine Numeric do
|
|
6
|
+
# @return [Money] self interpreted as BRL
|
|
6
7
|
def reais = Mint.money(self, 'BRL')
|
|
7
8
|
|
|
9
|
+
# @return [Money] self interpreted as USD
|
|
8
10
|
def dollars = Mint.money(self, 'USD')
|
|
9
11
|
|
|
12
|
+
# @return [Money] self interpreted as EUR
|
|
10
13
|
def euros = Mint.money(self, 'EUR')
|
|
11
14
|
|
|
15
|
+
# @param currency [String, Symbol, Currency] target currency
|
|
16
|
+
# @return [Money] self interpreted as the given currency
|
|
12
17
|
def to_money(currency) = Mint.money(self, currency)
|
|
13
18
|
|
|
14
19
|
alias_method :dollar, :dollars
|
|
15
20
|
alias_method :euro, :euros
|
|
16
21
|
alias_method :mint, :to_money
|
|
17
22
|
end
|
|
18
|
-
|
|
19
|
-
refine String do
|
|
20
|
-
def to_money(currency) = Mint.money(to_r, currency)
|
|
21
|
-
end
|
|
22
23
|
end
|
|
@@ -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'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :nodoc:
|
|
4
|
+
module Mint
|
|
5
|
+
class << self
|
|
6
|
+
# Optional callable that returns a Hash with locale-aware formatting defaults.
|
|
7
|
+
#
|
|
8
|
+
# The callable receives no arguments and returns a Hash with these keys:
|
|
9
|
+
# [+:decimal+] Decimal separator (e.g. +","+)
|
|
10
|
+
# [+:thousand+] Thousands delimiter (e.g. +"."+)
|
|
11
|
+
# [+:format+] Format template string (e.g. +"%<amount>f %<symbol>s"+)
|
|
12
|
+
#
|
|
13
|
+
# When set, +#to_s+ and +#format+ use these values as fallbacks when the
|
|
14
|
+
# corresponding parameter is not explicitly provided.
|
|
15
|
+
#
|
|
16
|
+
# @example Rails I18n integration (in minting-rails railtie)
|
|
17
|
+
# Mint.locale_backend = -> {
|
|
18
|
+
# fmt = I18n.t('number.currency.format')
|
|
19
|
+
# {
|
|
20
|
+
# decimal: fmt[:separator],
|
|
21
|
+
# thousand: fmt[:delimiter],
|
|
22
|
+
# format: fmt[:format] == '%n %u' ? '%<amount>f %<symbol>s' : '%<symbol>s%<amount>f'
|
|
23
|
+
# }
|
|
24
|
+
# }
|
|
25
|
+
#
|
|
26
|
+
# @return [Proc, #call, nil]
|
|
27
|
+
attr_accessor :locale_backend
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/minting/mint/mint.rb
CHANGED
|
@@ -14,20 +14,36 @@ module Mint
|
|
|
14
14
|
# @raise [ArgumentError] if the currency code is not registered
|
|
15
15
|
def self.money(amount, currency_code) = Money.create(amount, currency_code)
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# @return [Hash{String => Currency}] the frozen world-currencies hash
|
|
18
|
+
# @api private
|
|
19
|
+
def self.world_currencies = Registry.world_currencies
|
|
20
|
+
|
|
21
|
+
# Looks up a registered currency by its alpha code.
|
|
22
|
+
#
|
|
23
|
+
# Unlike {.currency}, this performs a direct hash lookup and only accepts strings.
|
|
19
24
|
#
|
|
20
|
-
# @param
|
|
21
|
-
# @return [Currency, nil] the registered Currency
|
|
22
|
-
def self.
|
|
23
|
-
|
|
24
|
-
when NilClass then nil
|
|
25
|
-
when Currency then currency
|
|
26
|
-
when String then CurrencyRegistry.currencies[currency]
|
|
27
|
-
else raise ArgumentError, "currency must be [Currency] ot [String] (#{currency})"
|
|
28
|
-
end
|
|
25
|
+
# @param code [String] the currency code
|
|
26
|
+
# @return [Currency, nil] the registered Currency, or +nil+ if not found
|
|
27
|
+
def self.currency_for_code(code)
|
|
28
|
+
Registry.currencies[code]
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Looks up a currency by its display symbol.
|
|
32
|
+
#
|
|
33
|
+
# @param symbol [String] the display symbol (e.g. "$", "R$")
|
|
34
|
+
# @return [Currency, nil] the highest-priority currency for the symbol
|
|
35
|
+
def self.currency_for_symbol(symbol)
|
|
36
|
+
Registry.currency_for_symbol(symbol)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns a zero {Money} in the given currency, useful as a default value
|
|
40
|
+
# for discounts, totals, or placeholders.
|
|
41
|
+
#
|
|
42
|
+
# @param currency [String, Currency] a currency code or object
|
|
43
|
+
# @return [Money] a frozen zero-Money
|
|
44
|
+
# @raise [ArgumentError] if the currency can't be resolved
|
|
45
|
+
def self.zero(currency) = Registry.zero_for(Currency.resolve!(currency))
|
|
46
|
+
|
|
31
47
|
# Registers a new currency, raising a KeyError if already registered.
|
|
32
48
|
#
|
|
33
49
|
# @param code [String] the unique currency code
|
|
@@ -38,6 +54,6 @@ module Mint
|
|
|
38
54
|
# @raise [ArgumentError] if the code contains invalid characters
|
|
39
55
|
# @raise [KeyError] if the currency code is already registered
|
|
40
56
|
def self.register_currency(code:, subunit: 0, symbol: '', priority: 0)
|
|
41
|
-
|
|
57
|
+
Registry.register(code:, subunit:, symbol:, priority:)
|
|
42
58
|
end
|
|
43
59
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
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 = Currency.resolve(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_for_code(code)
|
|
57
|
+
return currency if currency
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Registry.detect_currency(input) or raise ArgumentError, 'Currency could not be detected'
|
|
61
|
+
end
|
|
62
|
+
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
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module Registry
|
|
6
|
+
# Registers a new currency, raising a KeyError if already registered.
|
|
7
|
+
#
|
|
8
|
+
# @param code [String] the unique currency code
|
|
9
|
+
# @param subunit [Integer] the decimal subunit precision, defaults to 0
|
|
10
|
+
# @param symbol [String] the display symbol
|
|
11
|
+
# @param priority [Integer] parser precedence priority
|
|
12
|
+
# @return [Currency] the newly registered Currency instance
|
|
13
|
+
# @raise [ArgumentError] if the code contains invalid characters
|
|
14
|
+
# @raise [KeyError] if the currency code is already registered
|
|
15
|
+
def self.register(code:, subunit: 0, symbol: '', priority: 0)
|
|
16
|
+
raise ArgumentError, 'Currency code must be String' unless code.is_a? String
|
|
17
|
+
unless code.match?(/^[A-Z_]+$/)
|
|
18
|
+
raise ArgumentError,
|
|
19
|
+
"Currency code must have only letters or '_' ('USD',, 'MY_COIN')"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
MUTEX.synchronize do
|
|
23
|
+
raise KeyError, "Currency: #{code} already registered" if currencies[code]
|
|
24
|
+
|
|
25
|
+
currency = Currency.new(code:, subunit:, symbol:, priority:)
|
|
26
|
+
@currencies = @currencies.merge(code => currency).freeze
|
|
27
|
+
@currency_symbols = nil
|
|
28
|
+
@currency_symbol_map = nil
|
|
29
|
+
currency
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require_relative 'symbols'
|
|
5
|
+
require_relative 'registration'
|
|
6
|
+
require_relative 'zeros'
|
|
7
|
+
|
|
8
|
+
# Mint registry: manages all cached state
|
|
9
|
+
module Mint
|
|
10
|
+
# Internal registry for currencies, symbols, and zero-money cache.
|
|
11
|
+
# All mutable shared state lives here.
|
|
12
|
+
module Registry
|
|
13
|
+
MUTEX = Monitor.new
|
|
14
|
+
|
|
15
|
+
private_constant :MUTEX
|
|
16
|
+
|
|
17
|
+
# Loads ISO world currencies from YAML file.
|
|
18
|
+
#
|
|
19
|
+
# @return [Hash{String => Currency}] ISO-4217 world currencies mapped by code
|
|
20
|
+
# @api private
|
|
21
|
+
def self.world_currencies
|
|
22
|
+
@world_currencies || MUTEX.synchronize do
|
|
23
|
+
@world_currencies = begin
|
|
24
|
+
path = File.join(File.expand_path('../../data', __dir__), 'world-currencies.yaml')
|
|
25
|
+
YAML.load_file(path).to_h { |entry| [entry['code'], Currency.new(**entry.transform_keys(&:to_sym))] }
|
|
26
|
+
end.freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the frozen hash of all registered currencies (world + custom).
|
|
31
|
+
#
|
|
32
|
+
# @return [Hash{String => Currency}] registered currencies mapped by code
|
|
33
|
+
# @api private
|
|
34
|
+
def self.currencies
|
|
35
|
+
@currencies || MUTEX.synchronize { @currencies = world_currencies.dup.freeze }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module Registry
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
# Looks up a currency by its display symbol.
|
|
9
|
+
#
|
|
10
|
+
# @param symbol [String] the display symbol (e.g. "$", "R$")
|
|
11
|
+
# @return [Currency, nil] the highest-priority currency for the symbol
|
|
12
|
+
# @api private
|
|
13
|
+
def currency_for_symbol(symbol)
|
|
14
|
+
@currency_symbol_map || MUTEX.synchronize { @currency_symbol_map = currency_symbols.to_h.freeze }
|
|
15
|
+
@currency_symbol_map[symbol]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Scans +input+ for registered currency symbols and returns the first match.
|
|
19
|
+
#
|
|
20
|
+
# @param input [String] the string to scan
|
|
21
|
+
# @return [Currency, nil]
|
|
22
|
+
# @api private
|
|
23
|
+
def detect_currency(input)
|
|
24
|
+
currency_symbols.each do |symbol, currency|
|
|
25
|
+
return currency if input.include?(symbol)
|
|
26
|
+
end
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Registered symbols sorted for detection: longest match wins, then parser priority.
|
|
33
|
+
# Duplicate symbols are deduplicated — the highest-priority currency wins.
|
|
34
|
+
#
|
|
35
|
+
# @return [Array<Array<String, Currency>>] sorted symbol-to-currency mappings
|
|
36
|
+
# @api private
|
|
37
|
+
def currency_symbols
|
|
38
|
+
@currency_symbols || MUTEX.synchronize do
|
|
39
|
+
@currency_symbols =
|
|
40
|
+
currencies.values
|
|
41
|
+
.reject { |currency| currency.symbol.empty? }
|
|
42
|
+
.map { |currency| [currency.symbol, currency] }
|
|
43
|
+
.sort_by { |symbol, currency| [-symbol.length, -currency.priority] }
|
|
44
|
+
.uniq { |symbol, _| symbol }
|
|
45
|
+
.freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mint
|
|
4
|
+
# :nodoc:
|
|
5
|
+
module Registry
|
|
6
|
+
# Returns the cached zero-Money for a currency, creating it if needed.
|
|
7
|
+
#
|
|
8
|
+
# @param currency [Currency] the currency object
|
|
9
|
+
# @return [Money] a frozen zero-Money
|
|
10
|
+
# @api private
|
|
11
|
+
def self.zero_for(currency)
|
|
12
|
+
MUTEX.synchronize do
|
|
13
|
+
@zeros ||= {}
|
|
14
|
+
@zeros[currency] ||= Mint::Money.send(:new, 0, currency)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/minting/mint.rb
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
require 'minting/money/conversion'
|
|
17
|
-
require 'minting/money/formatting'
|
|
18
|
-
require 'minting/money/money'
|
|
3
|
+
require_relative 'currency/currency'
|
|
4
|
+
|
|
5
|
+
require_relative 'mint/dsl/numeric'
|
|
6
|
+
require_relative 'mint/dsl/range'
|
|
7
|
+
require_relative 'mint/dsl/string'
|
|
8
|
+
require_relative 'mint/dsl/top_level'
|
|
9
|
+
require_relative 'mint/locale_backend'
|
|
10
|
+
require_relative 'mint/mint'
|
|
11
|
+
require_relative 'mint/parser/parser'
|
|
12
|
+
require_relative 'mint/parser/separators'
|
|
13
|
+
require_relative 'mint/registry/registry'
|
|
14
|
+
|
|
15
|
+
require_relative '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
|