minting 1.5.1 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40050495b5105709bf403740b7373079ee354a005bd3057891a28f756043025a
4
- data.tar.gz: b6635617152e7794aefbf3b5ff2a601e8c6cf6a6f0b9660b69b985bde1d7f6d6
3
+ metadata.gz: 3c50c230bea20784e9834f9f0c2fd9968b73cb94cbd14aa06a0eb4e3a3e41d74
4
+ data.tar.gz: d57f1a4e5a4664c721a286e1ff93eba1558cbf24babeaca6615af755d53663eb
5
5
  SHA512:
6
- metadata.gz: 2fa5d0bcbe484c2cb2ae87f2bb770f4a9fe4aadbcc123ed8b5ae1cc3b20553bfcfc73bb76e85d90d363cf428ef38bebb687531bb687cec249579ccc0fcda3b21
7
- data.tar.gz: 4bcc01dea9e15a88552c171206b867aba3bf97a22cdd7e16b596b597527ae280ba9079e7ccd9bb61e498ba3f36c4bd17b4a29be949c8fea9caaaaa7f761a555d
6
+ metadata.gz: 1a4d800520eaa79c57a0cb0111e8b40dcf603fe49fdc697e2b065e3b295c2fe7599285f7af1bc987c0cec3ac13a94a95ef836eb741e86f34986e915e4f904daa
7
+ data.tar.gz: fc8840f398dead653854665cae2afea1f333e973678eac67df401c045af45ee5370a50f365debdd85f42404b80c10cd68c7bba5dc9b086afa2e9c705d323bbe2
data/README.md CHANGED
@@ -148,7 +148,7 @@ price.clamp(min_price, 100) #=> [USD 75.00]
148
148
 
149
149
  **Zero equality** — Any zero amount is considered equal across currencies and to numeric zero `Mint.money(0, 'USD') == Mint.money(0, 'EUR')` is intentionally `true`. Non-zero amounts must match currency and value.
150
150
 
151
- **Custom currencies** — `Mint.register_currency` returns the existing entry if the code is already registered; use `register_currency!` to detect duplicates.
151
+ **Custom currencies** — `Mint.register_currency`, Only registered currency codes and symbolos are recoginized by the parser.
152
152
 
153
153
  **Built-in currencies** — ISO-style codes ship in `lib/minting/data/currencies.yaml` and load when the registry is first accessed.
154
154
 
@@ -188,10 +188,10 @@ gem install minting
188
188
  ## Parsing strings
189
189
 
190
190
  ```ruby
191
- Mint::Money.parse('$19.99') #=> [USD 19.99]
192
- Mint::Money.parse('19,99 €') #=> [EUR 19.99]
193
- Mint::Money.parse('1.234,56', 'EUR') #=> [EUR 1234.56]
194
- Mint::Money.parse('USD 1,234.56') #=> [USD 1234.56]
191
+ Mint.parse('$19.99') #=> [USD 19.99]
192
+ Mint.parse('19,99 €') #=> [EUR 19.99]
193
+ Mint.parse('1.234,56', 'EUR') #=> [EUR 1234.56]
194
+ Mint.parse('USD 1,234.56') #=> [USD 1234.56]
195
195
  ```
196
196
 
197
197
  - Pass a currency code when the string has no symbol or code.
@@ -212,75 +212,10 @@ Bug reports and pull requests are welcome on GitHub at <https://github.com/gferr
212
212
 
213
213
  1. Fork and create a feature branch
214
214
  2. Run the test suite: `rake`
215
- 3. Run performance suites as needed: `BENCH=true rake bench:performance`
215
+ 3. Run performance suites as needed: `rake bench:performance`
216
216
  4. Open a PR with a clear description and benchmarks if relevant
217
217
 
218
218
 
219
- ## Performance
220
-
221
- This gem includes a performance suite under `test/performance`:
222
-
223
- - Core operations (creation, arithmetic, comparisons)
224
- - Algorithm benchmarks (split, allocate)
225
- - Memory and GC pressure tests
226
- - Competitive benchmarks vs `money` gem
227
-
228
- Run locally:
229
-
230
- ```bash
231
- # All performance suites
232
- BENCH=true rake bench:performance
233
-
234
- # Competitive vs money gem
235
- BENCH=true rake bench:competitive
236
-
237
- # Regression checks
238
- rake bench:regression
239
- ```
240
-
241
- ## Benchmark Summary: Minting vs Money Gem
242
-
243
- Generated by Qwen from the latest benchmark run on Ruby 4.0.1. - 2026-05-30
244
-
245
- ### Key Takeaways
246
-
247
- - **Mint is consistently faster** than the Money gem across all measured operations.
248
- - **Mint is 2.28x faster** in the 50,000-transaction simulation.
249
- - **Mint object creation is 2.76x faster** than `Money.from_amount`.
250
- - In formatting and conversion, Mint is often **10+x **.
251
- - Mint’s performance advantage is especially strong for numeric conversion, string formatting, comparisons, and high-volume transaction loops.
252
-
253
- ### Performance Highlights
254
-
255
- | Category | Mint | Money | Approx. Ratio |
256
- | --- | --- | --- | --- |
257
- | High-volume transactions | 195,412 ops/sec | 85,882 ops/sec | 2.28x faster |
258
- | `Mint.money` creation | 1.14M ops/sec | — | 2.76x faster than `Money.from_amount` |
259
- | `some.dollars` creation | 990k ops/sec | — | 1.15x faster than `Mint.money` |
260
- | `Money.new` creation | — | 715k ops/sec | Mint 1.59x faster |
261
- | `to_f` formatting | 8.8M–9.3M ops/sec | 0.7M ops/sec | ~12x faster |
262
- | `to_d` conversion | 2.1M–2.3M ops/sec | 0.73M–0.79M ops/sec | ~3x faster |
263
- | `to_s` formatting | 300k–420k ops/sec | 109k–132k ops/sec | ~3x faster |
264
- | `inspect` formatting | ~2.6–2.9M ops/sec | ~1.1–1.16M ops/sec | ~2.5x faster |
265
- | `to_json` formatting | ~2.0–2.2M ops/sec | ~110k–126k ops/sec | ~17x faster |
266
- | Currency lookup `Mint.currency('USD')` | 3.82M ops/sec | — | 1.60x faster than `Money::Currency.new` |
267
- | Currency lookup `Money::Currency.find('USD')` | 3.63M ops/sec | 1.67M ops/sec | 2.29x faster |
268
- | Addition | 1.11M ops/sec | 0.37M ops/sec | 3.0x faster |
269
- | Subtraction | 1.11M ops/sec | 0.36M ops/sec | 3.0x faster |
270
- | Multiplication | 1.28M ops/sec | 0.51M ops/sec | 2.5x faster |
271
- | Division | 1.04M ops/sec | 0.37M ops/sec | 2.8x faster |
272
- | Ratio division | 2.94M ops/sec | 0.39M ops/sec | 7.6x faster |
273
- | Comparison (`==`, `<`, `>`) | 2.5M–4.1M ops/sec | 0.35M–0.38M ops/sec | 7x–10x faster |
274
- | Allocation (`Mint.allocate`) | 279k ops/sec | 146k ops/sec | 1.9x faster |
275
- | Split (`Mint.split`) | 215k ops/sec | 85k ops/sec | 3.3x faster |
276
-
277
- ### Commands Used
278
-
279
- ```sh
280
- BENCH=true bundle exec ruby -Ilib:test -r ./test/test_helper.rb test/performance/competitive_performance_benchmark.rb
281
- BENCH=true bundle exec ruby -Ilib:test -r ./test/test_helper.rb test/performance/competitive_memory_benchmark.rb
282
- ```
283
-
284
219
  ## License
285
220
 
286
221
  MIT
data/Rakefile CHANGED
@@ -12,33 +12,29 @@ Rake::TestTask.new(:test) do |t|
12
12
  t.ruby_opts << '-rtest_helper.rb'
13
13
  end
14
14
 
15
- Rake::TestTask.new('bench') do |t|
15
+ Rake::TestTask.new('bench:all') do |t|
16
16
  t.libs = %w[lib test]
17
- t.pattern = 'test/performance/*_benchmark.rb'
17
+ t.pattern = 'test/performance/**/*_benchmark.rb'
18
18
  end
19
19
 
20
- Rake::TestTask.new('bench:parse') do |t|
20
+ Rake::TestTask.new('bench:core') do |t|
21
21
  t.libs = %w[lib test]
22
- t.pattern = 'test/performance/parse_benchmark.rb'
23
- t.ruby_opts << '-r test_helper.rb'
22
+ t.pattern = 'test/performance/core/parse_benchmark.rb'
24
23
  end
25
24
 
26
- Rake::TestTask.new('bench:edge') do |t|
25
+ Rake::TestTask.new('bench:memory') do |t|
27
26
  t.libs = %w[lib test]
28
- t.pattern = 'test/performance/algorithm_benchmark.rb'
29
- t.ruby_opts << '-r test_helper.rb'
27
+ t.pattern = 'test/performance/memory/*_benchmark.rb'
30
28
  end
31
29
 
32
30
  Rake::TestTask.new('bench:regression') do |t|
33
31
  t.libs = %w[lib test]
34
- t.pattern = 'test/performance/regression_benchmark.rb'
35
- t.ruby_opts << '-r test_helper.rb'
32
+ t.pattern = 'test/performance/regression/*_benchmark.rb'
36
33
  end
37
34
 
38
35
  Rake::TestTask.new('bench:competitive') do |t|
39
36
  t.libs = %w[lib test]
40
- t.pattern = 'test/performance/competitive_performance_benchmark.rb'
41
- t.ruby_opts << '-r test_helper.rb'
37
+ t.pattern = 'test/performance/competitive/**/*_benchmark.rb'
42
38
  end
43
39
 
44
40
  RuboCop::RakeTask.new(:cop)
data/doc/agents/AGENTS.md CHANGED
@@ -20,6 +20,6 @@ Project highlights & conventions:
20
20
  - Amounts stored as Rational; prefer rationals or decimal strings for precision
21
21
  - Zero-equality: zeros equal across currencies; non-zero comparisons require same currency
22
22
  - Currency codes must match /^[A-Z_]+$/
23
- - Tests: Minitest; performance benches under test/performance (use BENCH=true)
23
+ - Tests: Minitest; performance benches under test/performance
24
24
 
25
25
  Edit guidance: keep this file minimal and link to existing docs; add repo-specific agent tips here.
@@ -21,9 +21,9 @@ Build, test and lint commands
21
21
  - Run a single test method by name (Minitest -n regexp):
22
22
  - ruby -Ilib:test -r ./test/test_helper.rb test/money/money_test.rb -n /test_creation/
23
23
 
24
- - Performance suites (set BENCH=true as in README):
25
- - BENCH=true rake bench:performance
26
- - BENCH=true rake bench:competitive
24
+ - Performance suites:
25
+ - rake bench:performance
26
+ - rake bench:competitive
27
27
  - rake bench:regression
28
28
 
29
29
  - Linting:
@@ -56,7 +56,7 @@ Key conventions and repo-specific rules
56
56
  - Currency registration: use Mint.register_currency for idempotent registration; Mint.register_currency! raises on duplicates. Codes must match /^[A-Z_]+$/.
57
57
  - Symbol parsing: parser resolves symbols by longest match then currency priority (see Mint.currency_symbols sorting).
58
58
  - Tests: test_helper.rb configures coverage (SimpleCov) and loads minitest; when running tests outside rake, require test_helper (-r ./test/test_helper.rb).
59
- - Benchmarks: set BENCH=true to enable benchmark-heavy tasks; benchmarks use Minitest::Benchmark patterns.
59
+ - Benchmarks: set to enable benchmark-heavy tasks; benchmarks use Minitest::Benchmark patterns.
60
60
  - Formatting: Money.to_s uses Kernel.format patterns; take care with %<amount>f vs %<amount>d depending on desired rounding/formatting.
61
61
 
62
62
  Files and places to check first during edits
@@ -87,7 +87,7 @@ Qwen run on 2026-05-30 and aren't reproducible from CI.
87
87
  **Concrete action:** in CI, run
88
88
 
89
89
  ```sh
90
- BENCH=true rake bench:regression
90
+ ake bench:regression
91
91
  ```
92
92
 
93
93
  and fail the build if any benchmark regresses by more than (e.g.)
@@ -208,7 +208,7 @@ property test:
208
208
 
209
209
  ```ruby
210
210
  m = Mint.money(9.99, 'USD')
211
- assert_equal m, Mint::Money.parse(m.inspect.delete_prefix('[').delete_suffix(']'))
211
+ assert_equal m, Mint.parse(m.inspect.delete_prefix('[').delete_suffix(']'))
212
212
  ```
213
213
 
214
214
  Make it a one-liner or a property-based test with 10–20 random
@@ -709,3 +709,9 @@
709
709
  symbol: T$
710
710
  name: Tongan Paʻanga
711
711
  priority: 1
712
+ - code: XXX
713
+ country:
714
+ subunit: 0
715
+ symbol: ¤
716
+ name: No Currency
717
+ priority: 0
@@ -1,34 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mint
4
- # Represents a specific currency unit, identified by ISO 4217 alphabetic code
4
+ # Represents a specific currency unit, identified by ISO 4217 alphabetic code.
5
+ # Currency objects are immutable and define the properties of a monetary unit
6
+ # including its subunit precision, display symbol, and formatting rules.
5
7
  #
6
8
  # @see https://www.iso.org/iso-4217-currency-codes.html
7
- class Currency
8
- attr_reader :code, :subunit, :symbol,
9
- :country,
10
- :fractional_multiplier, :minimum_amount,
11
- :name, :priority
9
+ # @attr_reader code [String] ISO 4217 currency code (e.g., "USD", "EUR")
10
+ # @attr_reader subunit [Integer] Number of decimal places (0 for JPY, 2 for USD, 3 for IQD)
11
+ # @attr_reader symbol [String] Display symbol (e.g., "$", "€", "R$")
12
+ # @attr_reader priority [Integer] Parser precedence for symbol detection
13
+ # @attr_reader country [String, nil] Associated country code
14
+ # @attr_reader name [String, nil] Currency name
15
+ # @attr_reader fractional_multiplier [Integer] 10^subunit, used for fractional conversions
16
+ # @attr_reader minimum_amount [Rational] Smallest representable amount (1/fractional_multiplier)
17
+ Currency = Data.define(:code, :subunit, :symbol, :priority, :country, :name,
18
+ :fractional_multiplier) do
19
+ def initialize(code:, symbol:, subunit: 0, priority: 0, country: nil, name: nil)
20
+ subunit = subunit.to_i
21
+ priority = priority.to_i
22
+ fractional_multiplier = 10**subunit
23
+ super(code:, subunit:, symbol:, priority:, country:, name:,
24
+ fractional_multiplier:)
25
+ end
12
26
 
13
27
  def inspect = "<Currency:(#{code} #{symbol} #{subunit} #{name})>"
14
28
 
29
+ def minimum_amount = Rational(1, fractional_multiplier)
30
+
15
31
  # Normalizes numeric amounts for this currency
16
32
  # 1. Converts to Rational
17
33
  # 2. Rounds to respect currency subunit
18
34
  def normalize_amount(amount) = amount.to_r.round(subunit)
19
-
20
- private
21
-
22
- def initialize(code:, symbol:, subunit: 0, priority: 0, country: nil, name: nil)
23
- @code = code
24
- @subunit = subunit.to_i
25
- @symbol = symbol
26
- @priority = priority.to_i
27
- @country = country
28
- @name = name
29
- @fractional_multiplier = 10**@subunit
30
- @minimum_amount = Rational(1, fractional_multiplier)
31
- freeze
32
- end
33
35
  end
34
36
  end
@@ -4,25 +4,24 @@ require 'yaml'
4
4
 
5
5
  # Mint currency store (internal)
6
6
  module Mint
7
- # Internal currency storage and loading.
7
+ # Internal currency registry
8
8
  # Manages the registry cache and currency symbol lookups.
9
- module CurrencyStore
9
+ module Registry
10
+ module_function
11
+
10
12
  # Returns the hash of all registered currencies.
11
13
  #
12
14
  # @return [Hash{String => Currency}] registered currencies mapped by code
13
15
  # @api private
14
- def self.currencies
15
- @currencies ||= begin
16
- registry = { 'XXX' => Currency.new(code: 'XXX', name: 'No currency', symbol: '¤') }
17
- load_currencies(registry)
18
- end
16
+ def currencies
17
+ @currencies ||= Mint.world_currencies.dup
19
18
  end
20
19
 
21
20
  # Registered symbols sorted for detection: longest match wins, then parser priority.
22
21
  #
23
22
  # @return [Array<Array<String, Currency>>] sorted symbol-to-currency mappings
24
23
  # @api private
25
- def self.currency_symbols
24
+ def currency_symbols
26
25
  @currency_symbols ||= begin
27
26
  currencies.values
28
27
  .reject { |currency| currency.symbol.empty? }
@@ -35,34 +34,8 @@ module Mint
35
34
  # Called when currencies are registered.
36
35
  #
37
36
  # @api private
38
- def self.invalidate_symbols_cache
37
+ def invalidate_symbols_cache
39
38
  @currency_symbols = nil
40
39
  end
41
-
42
- # Loads currencies from YAML file into the registry.
43
- #
44
- # @param registry [Hash] the registry hash to populate
45
- # @return [Hash] the populated registry
46
- # @api private
47
- def self.load_currencies(registry)
48
- base = File.expand_path('../data', __dir__)
49
- path = File.join(base, 'currencies.yaml')
50
-
51
- data = YAML.load_file(path)
52
- data.each do |entry|
53
- code = entry['code']
54
- registry[code] = Currency.new(
55
- code: code,
56
- subunit: entry['subunit'],
57
- symbol: entry['symbol'],
58
- priority: entry['priority'],
59
- country: entry['country'],
60
- name: entry['name']
61
- )
62
- end
63
- registry
64
- end
65
-
66
- private_class_method :load_currencies
67
40
  end
68
41
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mint currency registration and factory (public API)
4
+ module Mint
5
+ # Unknown currency excpetion
6
+ class UnknownCurrency < StandardError
7
+ end
8
+
9
+ # Creates a new {Money} instance with the given amount and currency code.
10
+ #
11
+ # @param amount [Numeric] the financial value
12
+ # @param currency_code [Currency, String] Currency code
13
+ # @return [Money] the instantiated Money object
14
+ # @raise [ArgumentError] if the currency code is not registered
15
+ def self.money(amount, currency_code) = Money.create(amount, currency_code)
16
+
17
+ # Finds a registered currency by its code, symbol,
18
+ # or retrieves it directly if already a Currency object.
19
+ #
20
+ # @param currency [String, Currency] the currency identifier or object
21
+ # @return [Currency, nil] the registered Currency instance or nil if not found
22
+ def self.currency(currency)
23
+ case currency
24
+ when nil then nil
25
+ when Currency then currency
26
+ when String then Registry.currencies[currency]
27
+ else raise ArgumentError, "currency must be [Currency] ot [String] (#{currency})"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,78 @@
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
+ def parse_amount(input)
39
+ # Remove any charater that is not a digit, comma or period
40
+ numeric = input.scan(/[\d.,-]/).join
41
+ numeric = normalize_separators(numeric)
42
+ Rational(numeric)
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
61
+ end
62
+
63
+ def parse_currency(input)
64
+ case input
65
+ when nil then return nil
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])
69
+ return currency if currency
70
+
71
+ # Fall back to registered symbols, longest first (HK$ before $).
72
+ Mint.currency_symbols.each do |symbol, currency|
73
+ return currency if input.include?(symbol)
74
+ end
75
+ end
76
+ raise ArgumentError, 'Currency could not be detected'
77
+ end
78
+ end
@@ -25,8 +25,6 @@ module Mint
25
25
  end
26
26
 
27
27
  refine String do
28
- def to_money(currency)
29
- Mint.money(to_r, currency)
30
- end
28
+ def to_money(currency) = Mint.money(to_r, currency)
31
29
  end
32
30
  end
@@ -2,61 +2,27 @@
2
2
 
3
3
  # Mint currency registration and factory (public API)
4
4
  module Mint
5
- # Creates a new {Money} instance with the given amount and currency code.
6
- #
7
- # @param amount [Numeric] the financial value
8
- # @param currency_code [String] the ISO currency code
9
- # @return [Money] the instantiated Money object
10
- # @raise [ArgumentError] if the currency code is not registered
11
- def self.money(amount, currency_code)
12
- currency = currency(currency_code)
13
- return Money.create(amount, currency) if currency
14
-
15
- raise ArgumentError, "[#{currency.inspect}] is not a registered currency."
16
- end
17
-
18
- # Finds a registered currency by its code, symbol,
19
- # or retrieves it directly if already a Currency object.
20
- #
21
- # @param currency [String, Currency] the currency identifier or object
22
- # @return [Currency, nil] the registered Currency instance or nil if not found
23
- def self.currency(currency)
24
- currency.is_a?(Currency) ? currency : CurrencyStore.currencies[currency]
25
- end
26
-
27
- # Registers a new currency if not already registered.
28
- #
29
- # @param code [String] the unique currency code (e.g. 'USD', 'EUR')
30
- # @param subunit [Integer] the decimal subunit precision (defaults to 2)
31
- # @param symbol [String] the display symbol (defaults to '')
32
- # @param priority [Integer] parser precedence priority (defaults to 0)
33
- # @return [Currency] the registered or existing Currency instance
34
- # @raise [ArgumentError] if the code layout is invalid or register throws an error
35
- def self.register_currency(code:, subunit: 0, symbol: '', priority: 0)
36
- CurrencyStore.currencies[code] || register_currency!(code:, subunit:, symbol:, priority:)
37
- end
38
-
39
- # Strictly registers a new currency, raising a KeyError if already registered.
5
+ # Registers a new currency, raising a KeyError if already registered.
40
6
  #
41
7
  # @param code [String] the unique currency code
42
- # @param subunit [Integer] the decimal subunit precision
8
+ # @param subunit [Integer] the decimal subunit precision, defaults to 0
43
9
  # @param symbol [String] the display symbol
44
10
  # @param priority [Integer] parser precedence priority
45
11
  # @return [Currency] the newly registered Currency instance
46
12
  # @raise [ArgumentError] if the code contains invalid characters
47
13
  # @raise [KeyError] if the currency code is already registered
48
- def self.register_currency!(code:, subunit:, symbol: '', priority: 0)
14
+ def self.register_currency(code:, subunit: 0, symbol: '', priority: 0)
49
15
  raise ArgumentError, 'Currency code must be String' unless code.is_a? String
50
16
  unless code.match?(/^[A-Z_]+$/)
51
17
  raise ArgumentError,
52
- "Currency code must only letters or '_' ('USD',, 'MY_COIN')"
18
+ "Currency code must have only letters or '_' ('USD',, 'MY_COIN')"
53
19
  end
54
20
 
55
- currencies = CurrencyStore.currencies
21
+ currencies = Registry.currencies
56
22
  raise KeyError, "Currency: #{code} already registered" if currencies[code]
57
23
 
58
24
  currency = currencies[code] = Currency.new(code:, subunit:, symbol:, priority:)
59
- CurrencyStore.invalidate_symbols_cache
25
+ Registry.invalidate_symbols_cache
60
26
  currency
61
27
  end
62
28
 
@@ -66,6 +32,6 @@ module Mint
66
32
  # @return [Array<Array<String, Currency>>] sorted symbol-to-currency mappings
67
33
  # @api private
68
34
  def self.currency_symbols
69
- CurrencyStore.currency_symbols
35
+ Registry.currency_symbols
70
36
  end
71
37
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mint
4
+ module_function
5
+
6
+ # Loads ISO world currencies from YAML file into the registry.
7
+ #
8
+ # @return [Hash{String => Currency}] ISO-4217 world currencies mapped by code
9
+ # @api private
10
+ def world_currencies
11
+ @world_currencies ||= begin
12
+ path = File.join(File.expand_path('../data', __dir__), 'currencies.yaml')
13
+
14
+ YAML.load_file(path).to_h { |entry| [entry['code'], Currency.new(**entry.transform_keys(&:to_sym))] }
15
+ end.freeze
16
+ end
17
+ end
data/lib/minting/mint.rb CHANGED
@@ -2,5 +2,16 @@
2
2
 
3
3
  require 'minting/mint/currency'
4
4
  require 'minting/mint/currency_store'
5
+ require 'minting/mint/mint'
6
+ require 'minting/mint/parser'
5
7
  require 'minting/mint/registry'
6
8
  require 'minting/mint/refinements'
9
+ require 'minting/mint/world_currencies'
10
+ require 'minting/money/allocation'
11
+ require 'minting/money/arithmetics'
12
+ require 'minting/money/coercion'
13
+ require 'minting/money/comparable'
14
+ require 'minting/money/constructors'
15
+ require 'minting/money/conversion'
16
+ require 'minting/money/formatting'
17
+ require 'minting/money/money'
@@ -18,7 +18,7 @@ module Mint
18
18
  raise ArgumentError, 'Proportions total must not be zero' if whole.zero?
19
19
 
20
20
  subunit = currency.subunit
21
- amounts = proportions.map { |rate| (amount * rate.to_r / whole).round(subunit) }
21
+ amounts = proportions.map { |rate| Rational(amount * rate, whole).round(subunit) }
22
22
  allocate_left_over!(amounts: amounts, left_over: amount - amounts.sum)
23
23
  end
24
24
 
@@ -53,9 +53,7 @@ module Mint
53
53
  # Unary negation operator. Returns a new {Money} instance with the inverted sign.
54
54
  #
55
55
  # @return [Money] negated Money instance
56
- def -@
57
- mint(-amount)
58
- end
56
+ def -@ = mint(-amount)
59
57
 
60
58
  # Performs multiplication of the monetary value by a standard scalar Numeric.
61
59
  #
@@ -4,9 +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 + money` where `5` is a Numeric and `money` is a Money object.
7
8
  #
8
9
  # @param other [Numeric] the left-hand operand to coerce
9
10
  # @return [Array(CoercedNumber, Money)] coerced operand array
11
+ # @example
12
+ # price = Mint.money(10, 'USD')
13
+ # 5 + price #=> [USD 15.00] (via coercion)
10
14
  def coerce(other)
11
15
  [CoercedNumber.new(other), self]
12
16
  end
@@ -7,9 +7,11 @@ module Mint
7
7
 
8
8
  # @return true if both are zero, or both have same amount and same currency
9
9
  def ==(other)
10
- return true if zero? && other.respond_to?(:zero?) && other.zero?
11
-
12
- eql?(other)
10
+ case other
11
+ when 0 then zero?
12
+ when Mint::Money then amount == other.amount && currency == other.currency
13
+ else false
14
+ end
13
15
  end
14
16
 
15
17
  def eql?(other)
@@ -5,7 +5,7 @@ module Mint
5
5
  class Money
6
6
  # Creates a new Money immutable object with the specified amount and currency
7
7
  # @param amount [Numeric] The monetary amount
8
- # @param currency [Currency] The currency object
8
+ # @param currency [Currency, String] The currency code or currency object
9
9
  # @raise [ArgumentError] If amount is not numeric or currency is invalid
10
10
  def self.create(amount, currency)
11
11
  raise ArgumentError, 'amount must be Numeric' unless amount.is_a?(Numeric)
@@ -44,9 +44,16 @@ module Mint
44
44
  new(amount, checked_currency)
45
45
  end
46
46
 
47
- # Returns a new Money object with the specified amount, or self if unchanged
48
- # @param new_amount [Numeric] The new amount
49
- # @return [Money] A new Money object or self
47
+ # Returns a new Money object with the specified amount, or self if unchanged.
48
+ # This is the primary method for creating a modified copy of a Money instance
49
+ # while preserving immutability.
50
+ #
51
+ # @param new_amount [Numeric] The new monetary amount
52
+ # @return [Money] A new Money object with the new amount, or self if the amount is unchanged
53
+ # @example
54
+ # price = Mint.money(10.00, 'USD')
55
+ # price.mint(15.00) #=> [USD 15.00]
56
+ # price.mint(10.00) #=> [USD 10.00] (returns self)
50
57
  def mint(new_amount)
51
58
  new_amount = currency.normalize_amount(new_amount)
52
59
  new_amount == amount ? self : Money.new(new_amount, currency)
@@ -9,6 +9,8 @@ module Mint
9
9
  # Converts the monetary amount to a BigDecimal object.
10
10
  #
11
11
  # @return [BigDecimal] the decimal representation of the money amount
12
+ # @example
13
+ # Mint.money(9.99, 'USD').to_d #=> 0.999e1
12
14
  def to_d = amount.to_d 0
13
15
 
14
16
  # Converts the monetary amount to a standard float.
@@ -31,8 +33,17 @@ module Mint
31
33
  # Truncates and converts the monetary amount to an Integer.
32
34
  #
33
35
  # @return [Integer] the integer representation of the money amount
36
+ # @example
37
+ # Mint.money(9.99, 'USD').to_i #=> 9
38
+ # Mint.money(-9.99, 'USD').to_i #=> -9
34
39
  def to_i = amount.to_i
35
40
 
41
+ # Returns a Hash representation of the money instance.
42
+ #
43
+ # @return [Hash] hash with :currency (String) and :amount (String) keys
44
+ # @example
45
+ # Mint.money(134120, 'BRL').to_hash
46
+ # #=> { currency: "BRL", amount: "134120.00" }
36
47
  def to_hash
37
48
  { currency: currency_code, amount: Kernel.format("%0.#{currency.subunit}f", amount) }
38
49
  end
@@ -11,9 +11,19 @@ module Mint
11
11
 
12
12
  # Returns the ISO 3-letter currency code string.
13
13
  #
14
- # @return [String] the ISO currency code
14
+ # @return [String] the ISO currency code (e.g., "USD", "EUR", "BRL")
15
+ # @example
16
+ # Mint.money(100, 'USD').currency_code #=> "USD"
15
17
  def currency_code = currency.code
16
18
 
19
+ # Returns the monetary amount expressed in the currency's smallest unit (fractional units).
20
+ # For example, cents for USD (subunit 2), yen for JPY (subunit 0), fils for IQD (subunit 3).
21
+ #
22
+ # @return [Integer] the amount in fractional units
23
+ # @example
24
+ # Mint.money(1234.56, 'USD').fractional #=> 123456
25
+ # Mint.money(1000, 'JPY').fractional #=> 1000
26
+ # Mint.money(123.456, 'IQD').fractional #=> 123456
17
27
  def fractional = (amount * currency.fractional_multiplier).to_i
18
28
 
19
29
  # Generates a stable hash key for Money instances.
@@ -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.5.1'
6
+ VERSION = '1.6.1'
7
7
  end
data/lib/minting.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'minting/mint'
4
- require 'minting/money'
5
4
  require 'minting/version'
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.5.1
4
+ version: 1.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gilson Ferraz
@@ -44,9 +44,11 @@ files:
44
44
  - lib/minting/mint.rb
45
45
  - lib/minting/mint/currency.rb
46
46
  - lib/minting/mint/currency_store.rb
47
+ - lib/minting/mint/mint.rb
48
+ - lib/minting/mint/parser.rb
47
49
  - lib/minting/mint/refinements.rb
48
50
  - lib/minting/mint/registry.rb
49
- - lib/minting/money.rb
51
+ - lib/minting/mint/world_currencies.rb
50
52
  - lib/minting/money/allocation.rb
51
53
  - lib/minting/money/arithmetics.rb
52
54
  - lib/minting/money/coercion.rb
@@ -55,7 +57,6 @@ files:
55
57
  - lib/minting/money/conversion.rb
56
58
  - lib/minting/money/formatting.rb
57
59
  - lib/minting/money/money.rb
58
- - lib/minting/money/parse.rb
59
60
  - lib/minting/version.rb
60
61
  - minting.gemspec
61
62
  homepage: https://github.com/gferraz/minting
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mint
4
- # Money parser
5
- class Money
6
- # Parses a human-readable money string into a {Money} object.
7
- #
8
- # @param input [String] Amount input, optionally including a currency symbol or code
9
- # @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
10
- # @return [Money]
11
- # @raise [ArgumentError] when +input+ is invalid or currency cannot be determined
12
- #
13
- # @example With explicit currency
14
- # Money.parse('19.99', 'USD') #=> [USD 19.99]
15
- # Money.parse('1.234,56', 'EUR') #=> [EUR 1234.56]
16
- #
17
- # @example With symbol or code in the string
18
- # Money.parse('$19.99') #=> [USD 19.99]
19
- # Money.parse('19,99 €') #=> [EUR 19.99]
20
- # Money.parse('USD 1,234.56') #=> [USD 1234.56]
21
- def self.parse(input, currency = nil)
22
- raise ArgumentError, 'input must be a String' unless input.is_a?(String)
23
-
24
- input = input.strip
25
- raise ArgumentError, 'input cannot be empty' if input.empty?
26
-
27
- currency = parse_currency(currency) || parse_currency(input)
28
- raise ArgumentError, "Currency [#{currency}] not registered" unless currency
29
-
30
- amount = currency.normalize_amount(parse_amount(input))
31
- new(amount, currency)
32
- end
33
-
34
- # Extracts a numeric value from input that should only contain an amount.
35
- def self.parse_amount(input)
36
- # Remove any charater that is not a digit, comma or period
37
- numeric = input.scan(/[\d.,-]/).join
38
- numeric = normalize_separators(numeric)
39
- Rational(numeric)
40
- end
41
-
42
- # Converts locale-specific decimal/thousand separators into a plain decimal string.
43
- def self.normalize_separators(numeric)
44
- case [numeric.count(','), numeric.count('.')]
45
- in [0, 0] | [0, 1] then numeric # Nothing to normalize (e.g. "1500" or "34.21").
46
- in [1, 0] then numeric.tr(',', '.') # Only one comma: decimal (e.g. 19,99 or 1,234).
47
- in [c, p] if c > 1 && p > 1 # Both separators appear multiple times
48
- raise ArgumentError, "could not distinguish decimal and thousand separators in '#{numeric}'"
49
- in [c, p] if c > 0 && p > 0 # Commas and dots: the rightmost one is the decimal separator.
50
- if numeric.rindex(',') > numeric.rindex('.')
51
- numeric.delete('.').tr(',', '.')
52
- else
53
- numeric.delete(',')
54
- end
55
- else # Multiple of the same separator only (e.g. 1,234,567) — all are thousands.
56
- numeric.delete(',.')
57
- end
58
- end
59
-
60
- def self.parse_currency(input)
61
- case input
62
- when NilClass, Mint::Currency then return input
63
- when String
64
- # Prefer an explicit ISO 4217 code (e.g. "USD 1,234.56") over symbol matching.
65
- currency = Mint.currency(input[/\b([A-Z]+)\b/, 1])
66
- return currency if currency
67
-
68
- # Fall back to registered symbols, longest first (HK$ before $).
69
- Mint.currency_symbols.each do |symbol, currency|
70
- return currency if input.include?(symbol)
71
- end
72
- end
73
- raise ArgumentError, 'currency could not be detected; pass a currency code as the second argument'
74
- end
75
-
76
- private_class_method :parse_amount, :normalize_separators,
77
- :parse_currency
78
- end
79
- end
data/lib/minting/money.rb DELETED
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'minting/money/parse'
4
- require 'minting/money/allocation'
5
- require 'minting/money/arithmetics'
6
- require 'minting/money/coercion'
7
- require 'minting/money/comparable'
8
- require 'minting/money/constructors'
9
- require 'minting/money/conversion'
10
- require 'minting/money/formatting'
11
- require 'minting/money/money'