amounts 0.0.2 → 0.0.4
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/CHANGELOG.md +60 -0
- data/lib/amount/active_record/rspec/matchers.rb +50 -0
- data/lib/amount/active_record/rspec.rb +3 -2
- data/lib/amount/active_record/type.rb +3 -4
- data/lib/amount/allocation.rb +66 -0
- data/lib/amount/arithmetic.rb +91 -0
- data/lib/amount/comparison.rb +78 -0
- data/lib/amount/conversion.rb +41 -0
- data/lib/amount/display.rb +2 -2
- data/lib/amount/parser.rb +1 -1
- data/lib/amount/registry.rb +17 -8
- data/lib/amount/rspec/matchers.rb +74 -0
- data/lib/amount/rspec/support.rb +49 -0
- data/lib/amount/rspec.rb +9 -9
- data/lib/amount/serialization.rb +54 -0
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +49 -272
- data/test/test_amount.rb +65 -0
- metadata +9 -4
- data/lib/amount/rspec_matchers.rb +0 -105
- data/lib/amount/rspec_support.rb +0 -47
- data/lib/amount/serializer.rb +0 -35
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b7620b025be07f1166e655108c2b9cea0232acb60d814cf91b71ecb47ddf1fb
|
|
4
|
+
data.tar.gz: 8f8ab43608f4ced54ab5020c7cbcf69b0741c9203e549558b27356766187efb5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 466350a8fbfff01e84bd044f23ced9d63288eb4147f13d70df2a3db9b37745cafee46e03cb3d1d9b0b805fff8ece3ca13df029c19786204f21e2fe1336b1e564
|
|
7
|
+
data.tar.gz: 807ba283121158491de02a78084dae5ce12c6360ac106a7d72a1bde6a7f536ac3009fdf329b9e75972fe3631ade8bdae3dd0f2d1f2d2e74f9a023f28d46f5cb1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.4 - 2026-04-26
|
|
4
|
+
|
|
5
|
+
### Changed (breaking)
|
|
6
|
+
|
|
7
|
+
- The opt-in RSpec integration moved into a dedicated `Amount::RSpec` namespace
|
|
8
|
+
with one file per concern. Constants and require paths changed:
|
|
9
|
+
|
|
10
|
+
| Old | New |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `Amount::RSpecMatchers` | `Amount::RSpec::Matchers` |
|
|
13
|
+
| `Amount::RSpecSupport` | `Amount::RSpec::Support` |
|
|
14
|
+
| `lib/amount/rspec_matchers.rb` | `lib/amount/rspec/matchers.rb` |
|
|
15
|
+
| `lib/amount/rspec_support.rb` | `lib/amount/rspec/support.rb` |
|
|
16
|
+
|
|
17
|
+
The ActiveRecord-specific matchers also live in their own file under the same
|
|
18
|
+
pattern (`Amount::ActiveRecord::RSpec::Matchers` in
|
|
19
|
+
`lib/amount/active_record/rspec/matchers.rb`). Top-level `require` paths
|
|
20
|
+
(`require "amount/rspec"` and `require "amount/active_record/rspec"`) are
|
|
21
|
+
unchanged.
|
|
22
|
+
|
|
23
|
+
Update any direct constant references; no backwards-compatibility aliases are
|
|
24
|
+
shipped.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- The `Amount` instance behavior is now composed from focused mixins instead
|
|
29
|
+
of all living in a single 500+ line file. New modules under `lib/amount/`:
|
|
30
|
+
`Arithmetic` (`+`, `-`, `*`, `/`, `abs`, `-@`),
|
|
31
|
+
`Comparison` (`<=>`, `==`, `eql?`, `hash`, `same_type?`, sign predicates),
|
|
32
|
+
`Conversion` (`to(target_symbol, rate:)`),
|
|
33
|
+
`Allocation` (`split`, `allocate`),
|
|
34
|
+
and `Serialization` (instance `to_h` plus `Serialization::ClassMethods.load`
|
|
35
|
+
auto-extended via the `included` hook).
|
|
36
|
+
Public API unchanged. Shared private helpers (`build`,
|
|
37
|
+
`coerce_other_to_self_type[!]`, `ensure_same_type!`, `infer_value`,
|
|
38
|
+
`infer_type`, `ui_to_atomic`) remain on the main `Amount` class so every
|
|
39
|
+
mixin can call them.
|
|
40
|
+
|
|
41
|
+
### Removed
|
|
42
|
+
|
|
43
|
+
- `Amount::Serializer` is gone. Its `dump`/`load` class methods moved into
|
|
44
|
+
`Amount::Serialization` (instance `to_h` plus `ClassMethods.load`).
|
|
45
|
+
`Amount.load(hash)` and `Amount#to_h` are unchanged.
|
|
46
|
+
|
|
47
|
+
## 0.0.3 - 2026-04-26
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- Rational scalars and rates are now accepted everywhere they are documented to
|
|
52
|
+
work: `Amount#*`, `Amount#/`, `Amount#to(rate:)`, `Amount.register_default_rate`,
|
|
53
|
+
and `display_units[unit][:scale]`. Previously each of these called
|
|
54
|
+
`BigDecimal(value.to_s)`, which raises `ArgumentError` for `Rational#to_s`
|
|
55
|
+
(`"3/2"`). All five sites now go through a single internal helper,
|
|
56
|
+
`Amount.coerce_decimal`, that handles `BigDecimal`, `Rational`, and string-or-
|
|
57
|
+
numeric inputs uniformly.
|
|
58
|
+
- `Amount.load` wraps a missing `:atomic` or `:symbol` key as
|
|
59
|
+
`Amount::InvalidInput` (`"amount payload missing key: atomic"`) instead of
|
|
60
|
+
leaking a raw `KeyError`. This matches the AR adapter's `Type#cast_hash`
|
|
61
|
+
behavior so callers can rescue a single error class.
|
|
62
|
+
|
|
3
63
|
## 0.0.2 - 2026-04-26
|
|
4
64
|
|
|
5
65
|
### Fixed
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module RSpec
|
|
6
|
+
# ActiveRecord-specific matcher definitions. Companion to
|
|
7
|
+
# `Amount::RSpec::Matchers` which holds the gem-core matchers.
|
|
8
|
+
module Matchers
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def define_amount_column_matcher
|
|
12
|
+
::RSpec::Matchers.define :have_amount_column do |name, *expected_arguments|
|
|
13
|
+
match do |record|
|
|
14
|
+
@name = name
|
|
15
|
+
@expected = Amount::RSpec::Support.coerce_amount_arguments(expected_arguments)
|
|
16
|
+
@definition = record.class.amount_attribute_definitions.fetch(name.to_sym)
|
|
17
|
+
|
|
18
|
+
@definition.read(record) == @expected &&
|
|
19
|
+
record.public_send(@definition.atomic_column).to_i == @expected.atomic &&
|
|
20
|
+
(@definition.fixed_symbol? ||
|
|
21
|
+
record.public_send(@definition.symbol_column) == @expected.symbol.to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
failure_message do |record|
|
|
25
|
+
"expected #{record.inspect} to have #{@name} column matching #{@expected.inspect}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def define_amount_sum_matcher
|
|
31
|
+
::RSpec::Matchers.define :match_amounts do |expected_hash|
|
|
32
|
+
match do |actual_hash|
|
|
33
|
+
@expected = expected_hash.to_h do |symbol, value|
|
|
34
|
+
amount = Amount.new(value, symbol)
|
|
35
|
+
[amount.symbol, amount]
|
|
36
|
+
end
|
|
37
|
+
@actual = Amount::RSpec::Support.normalize_amount_sums(actual_hash)
|
|
38
|
+
|
|
39
|
+
@actual == @expected
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
failure_message do |_actual_hash|
|
|
43
|
+
"expected grouped amounts #{@actual.inspect} to match #{@expected.inspect}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../active_record"
|
|
4
4
|
require "rspec/expectations"
|
|
5
5
|
require_relative "../rspec"
|
|
6
|
+
require_relative "rspec/matchers"
|
|
6
7
|
|
|
7
|
-
Amount::
|
|
8
|
-
Amount::
|
|
8
|
+
Amount::ActiveRecord::RSpec::Matchers.define_amount_column_matcher
|
|
9
|
+
Amount::ActiveRecord::RSpec::Matchers.define_amount_sum_matcher
|
|
@@ -77,12 +77,11 @@ class Amount
|
|
|
77
77
|
private
|
|
78
78
|
|
|
79
79
|
def cast_hash(value)
|
|
80
|
-
|
|
80
|
+
value = value.transform_keys(&:to_sym)
|
|
81
|
+
if value.key?(:atomic)
|
|
81
82
|
::Amount.load(value)
|
|
82
83
|
else
|
|
83
|
-
|
|
84
|
-
amount_value = value.fetch(:value) { value.fetch("value") }
|
|
85
|
-
::Amount.new(amount_value, symbol)
|
|
84
|
+
::Amount.new(value.fetch(:value), value.fetch(:symbol, fixed_symbol))
|
|
86
85
|
end
|
|
87
86
|
rescue KeyError
|
|
88
87
|
raise ::Amount::InvalidInput, "hash input must contain atomic/symbol or value/symbol"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Splitting and proportional allocation. Both operations return
|
|
5
|
+
# `[parts, remainder]`, preserving the invariant that the parts plus
|
|
6
|
+
# remainder always sum back to the receiver's atomic value (including
|
|
7
|
+
# the sign).
|
|
8
|
+
module Allocation
|
|
9
|
+
# Splits into equal parts and returns the leftover explicitly.
|
|
10
|
+
#
|
|
11
|
+
# @param n [Integer]
|
|
12
|
+
# @return [Array<(Array<Amount>, Amount)>]
|
|
13
|
+
# @raise [ArgumentError] if `n` is not a positive integer
|
|
14
|
+
# @example
|
|
15
|
+
# parts, remainder = Amount.new(10, :LOGS).split(3)
|
|
16
|
+
# parts.map(&:atomic)
|
|
17
|
+
# # => [3, 3, 3]
|
|
18
|
+
# remainder.atomic
|
|
19
|
+
# # => 1
|
|
20
|
+
def split(n)
|
|
21
|
+
raise ArgumentError, "n must be positive" unless n.is_a?(Integer) && n.positive?
|
|
22
|
+
|
|
23
|
+
sign = atomic_sign
|
|
24
|
+
base, remainder = @atomic.abs.divmod(n)
|
|
25
|
+
parts = Array.new(n) { build(sign * base) }
|
|
26
|
+
|
|
27
|
+
[parts, build(sign * remainder)]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Allocates proportionally by integer weights and returns the leftover explicitly.
|
|
31
|
+
#
|
|
32
|
+
# @param weights [Array<Integer>]
|
|
33
|
+
# @return [Array<(Array<Amount>, Amount)>]
|
|
34
|
+
# @raise [ArgumentError] if weights are empty, negative, or sum to zero
|
|
35
|
+
# @example
|
|
36
|
+
# parts, remainder = Amount.new(10, :LOGS).allocate([1, 1, 2])
|
|
37
|
+
# parts.map(&:atomic)
|
|
38
|
+
# # => [2, 2, 5]
|
|
39
|
+
# remainder.atomic
|
|
40
|
+
# # => 1
|
|
41
|
+
def allocate(weights)
|
|
42
|
+
raise ArgumentError, "weights must be non-empty" if weights.empty?
|
|
43
|
+
raise ArgumentError, "weights must be non-negative integers" unless weights.all? { |weight| weight.is_a?(Integer) && weight >= 0 }
|
|
44
|
+
|
|
45
|
+
total = weights.sum
|
|
46
|
+
raise ArgumentError, "weights must sum to positive value" unless total.positive?
|
|
47
|
+
|
|
48
|
+
sign = atomic_sign
|
|
49
|
+
absolute_atomic = @atomic.abs
|
|
50
|
+
allocations = weights.map { |weight| absolute_atomic * weight / total }
|
|
51
|
+
remainder = absolute_atomic - allocations.sum
|
|
52
|
+
|
|
53
|
+
parts = allocations.map { |allocation| build(sign * allocation) }
|
|
54
|
+
[parts, build(sign * remainder)]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def atomic_sign
|
|
60
|
+
return 1 if @atomic.positive?
|
|
61
|
+
return(-1) if @atomic.negative?
|
|
62
|
+
|
|
63
|
+
0
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Arithmetic operators for `Amount`. Mixed into `Amount` and inherited by
|
|
5
|
+
# any registered subclass.
|
|
6
|
+
#
|
|
7
|
+
# All operators preserve the receiver's class via `build`, so subclass
|
|
8
|
+
# identity (`GoldAmount`) survives through `+`, `-`, `*`, `/`, `abs`, `-@`.
|
|
9
|
+
module Arithmetic
|
|
10
|
+
# @return [Amount]
|
|
11
|
+
# @example
|
|
12
|
+
# Amount.usdc("-1").abs
|
|
13
|
+
# # => #<Amount USDC $1.00>
|
|
14
|
+
def abs
|
|
15
|
+
build(@atomic.abs)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Amount]
|
|
19
|
+
# @example
|
|
20
|
+
# -Amount.usdc("1")
|
|
21
|
+
# # => #<Amount USDC -$1.00>
|
|
22
|
+
def -@
|
|
23
|
+
build(-@atomic)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param other [Amount]
|
|
27
|
+
# @return [Amount]
|
|
28
|
+
# @raise [TypeMismatch]
|
|
29
|
+
# @example Same-type addition
|
|
30
|
+
# Amount.usdc("1.50") + Amount.usdc("0.50")
|
|
31
|
+
#
|
|
32
|
+
# @example Cross-type addition using a registered directional rate
|
|
33
|
+
# Amount.register_default_rate :USD, :USDC, "1"
|
|
34
|
+
# Amount.usdc("10.00") + Amount.new("5.00", :USD)
|
|
35
|
+
def +(other)
|
|
36
|
+
rhs = coerce_other_to_self_type!(other)
|
|
37
|
+
build(@atomic + rhs.atomic)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param other [Amount]
|
|
41
|
+
# @return [Amount]
|
|
42
|
+
# @raise [TypeMismatch]
|
|
43
|
+
# @example
|
|
44
|
+
# Amount.usdc("2.00") - Amount.usdc("0.50")
|
|
45
|
+
def -(other)
|
|
46
|
+
rhs = coerce_other_to_self_type!(other)
|
|
47
|
+
build(@atomic - rhs.atomic)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param scalar [Numeric]
|
|
51
|
+
# @return [Amount]
|
|
52
|
+
# @raise [TypeMismatch]
|
|
53
|
+
# @example
|
|
54
|
+
# Amount.usdc("1.25") * 2
|
|
55
|
+
def *(scalar)
|
|
56
|
+
ensure_scalar!(scalar)
|
|
57
|
+
build((BigDecimal(@atomic) * Amount.coerce_decimal(scalar)).to_i)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param other [Amount, Numeric]
|
|
61
|
+
# @return [Amount, BigDecimal]
|
|
62
|
+
# @raise [TypeMismatch, ZeroDivisionError]
|
|
63
|
+
# @example Dividing by a scalar returns an amount
|
|
64
|
+
# Amount.usdc("1.00") / 2
|
|
65
|
+
#
|
|
66
|
+
# @example Dividing by an amount returns a ratio
|
|
67
|
+
# Amount.usdc("10.00") / Amount.usdc("2.00")
|
|
68
|
+
def /(other)
|
|
69
|
+
if other.is_a?(Amount)
|
|
70
|
+
ensure_same_type!(other)
|
|
71
|
+
raise ZeroDivisionError if other.zero?
|
|
72
|
+
|
|
73
|
+
BigDecimal(@atomic) / BigDecimal(other.atomic)
|
|
74
|
+
else
|
|
75
|
+
ensure_scalar!(other)
|
|
76
|
+
raise ZeroDivisionError if other.zero?
|
|
77
|
+
|
|
78
|
+
build((BigDecimal(@atomic) / Amount.coerce_decimal(other)).to_i)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def ensure_scalar!(value)
|
|
85
|
+
return if value.is_a?(Integer) || value.is_a?(Float) ||
|
|
86
|
+
value.is_a?(BigDecimal) || value.is_a?(Rational)
|
|
87
|
+
|
|
88
|
+
raise TypeMismatch, "expected scalar, got #{value.class}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Comparison, equality, hashing, and sign predicates for `Amount`.
|
|
5
|
+
#
|
|
6
|
+
# Pulls in `Comparable` itself so consumers only need `include Comparison`
|
|
7
|
+
# to get `<`, `<=`, `>`, `>=`, `between?`, `clamp`, and `Enumerable#min`/
|
|
8
|
+
# `#max` alongside the explicit `<=>` / `==` / `eql?` / `hash` defined here.
|
|
9
|
+
module Comparison
|
|
10
|
+
include Comparable
|
|
11
|
+
# @param other [Object]
|
|
12
|
+
# @return [Boolean]
|
|
13
|
+
# @example
|
|
14
|
+
# Amount.usdc("1").same_type?(Amount.usdc("2"))
|
|
15
|
+
# # => true
|
|
16
|
+
def same_type?(other)
|
|
17
|
+
other.is_a?(Amount) && other.symbol == symbol
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Boolean]
|
|
21
|
+
# @example
|
|
22
|
+
# Amount.usdc(0, from: :atomic).zero?
|
|
23
|
+
# # => true
|
|
24
|
+
def zero? = @atomic.zero?
|
|
25
|
+
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
# @example
|
|
28
|
+
# Amount.usdc("1").positive?
|
|
29
|
+
# # => true
|
|
30
|
+
def positive? = @atomic.positive?
|
|
31
|
+
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
# @example
|
|
34
|
+
# Amount.usdc("-1").negative?
|
|
35
|
+
# # => true
|
|
36
|
+
def negative? = @atomic.negative?
|
|
37
|
+
|
|
38
|
+
# @param other [Object]
|
|
39
|
+
# @return [-1, 0, 1, nil]
|
|
40
|
+
# @example
|
|
41
|
+
# Amount.usdc("1") <=> Amount.usdc("2")
|
|
42
|
+
# # => -1
|
|
43
|
+
def <=>(other)
|
|
44
|
+
return nil unless other.is_a?(Amount)
|
|
45
|
+
|
|
46
|
+
comparable = coerce_other_to_self_type(other)
|
|
47
|
+
return nil unless comparable
|
|
48
|
+
|
|
49
|
+
@atomic <=> comparable.atomic
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param other [Object]
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
# @example
|
|
55
|
+
# Amount.usdc("1.50") == Amount.usdc("1.50")
|
|
56
|
+
# # => true
|
|
57
|
+
def ==(other)
|
|
58
|
+
same_type?(other) && @atomic == other.atomic
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param other [Object]
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
# @example Hash-key equality keeps class and symbol identity
|
|
64
|
+
# Amount.usdc("1").eql?(Amount.usdc("1"))
|
|
65
|
+
# # => true
|
|
66
|
+
def eql?(other)
|
|
67
|
+
other.class == self.class && symbol == other.symbol && @atomic == other.atomic
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Integer]
|
|
71
|
+
# @example
|
|
72
|
+
# { Amount.usdc("1") => :ok }[Amount.usdc("1")]
|
|
73
|
+
# # => :ok
|
|
74
|
+
def hash
|
|
75
|
+
[self.class, symbol, @atomic].hash
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Cross-type conversion via `to(target_symbol, rate:)`. Uses an explicitly
|
|
5
|
+
# passed rate when provided, otherwise looks up the registered directional
|
|
6
|
+
# default rate. The result class is taken from the target symbol's registry
|
|
7
|
+
# entry, so a `class:`-registered subclass becomes the natural identity for
|
|
8
|
+
# conversion outputs.
|
|
9
|
+
module Conversion
|
|
10
|
+
# @param target_symbol [Symbol, String]
|
|
11
|
+
# @param rate [String, Numeric, BigDecimal, nil]
|
|
12
|
+
# @return [Amount]
|
|
13
|
+
# @raise [Amount::Registry::NoDefaultRate] if no explicit or registered rate is available
|
|
14
|
+
# @example Using an explicit one-off rate
|
|
15
|
+
# Amount.usdc("100").to(:GOLD, rate: "0.00042")
|
|
16
|
+
#
|
|
17
|
+
# @example Using a registered default rate
|
|
18
|
+
# Amount.register_default_rate :USDC, :USD, "1"
|
|
19
|
+
# Amount.usdc("1.50").to(:USD)
|
|
20
|
+
def to(target_symbol, rate: nil)
|
|
21
|
+
target_symbol = target_symbol.to_sym
|
|
22
|
+
return self.class.new(@atomic, symbol, from: :atomic) if target_symbol == symbol
|
|
23
|
+
|
|
24
|
+
rate = resolve_rate(target_symbol, rate)
|
|
25
|
+
target_entry = self.class.registry.lookup(target_symbol)
|
|
26
|
+
|
|
27
|
+
decimal_result = decimal * Amount.coerce_decimal(rate)
|
|
28
|
+
atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
|
|
29
|
+
|
|
30
|
+
target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def resolve_rate(target, provided)
|
|
36
|
+
return provided if provided
|
|
37
|
+
|
|
38
|
+
self.class.registry.default_rate(symbol, target)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/amount/display.rb
CHANGED
|
@@ -32,7 +32,7 @@ class Amount
|
|
|
32
32
|
# @return [BigDecimal]
|
|
33
33
|
def in_unit(unit)
|
|
34
34
|
unit_spec = fetch_display_unit(unit)
|
|
35
|
-
@amount.decimal *
|
|
35
|
+
@amount.decimal * Amount.coerce_decimal(unit_spec[:scale])
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
private
|
|
@@ -44,7 +44,7 @@ class Amount
|
|
|
44
44
|
|
|
45
45
|
def render_display_unit(unit, direction)
|
|
46
46
|
spec = fetch_display_unit(unit)
|
|
47
|
-
scaled = @amount.decimal *
|
|
47
|
+
scaled = @amount.decimal * Amount.coerce_decimal(spec[:scale])
|
|
48
48
|
decimals = spec[:ui_decimals] || @entry.ui_decimals
|
|
49
49
|
rounded = round(scaled, decimals, direction)
|
|
50
50
|
|
data/lib/amount/parser.rb
CHANGED
data/lib/amount/registry.rb
CHANGED
|
@@ -21,23 +21,30 @@ class Amount
|
|
|
21
21
|
# Amount.registry.lock!
|
|
22
22
|
class Registry
|
|
23
23
|
class UnknownType < StandardError; end
|
|
24
|
-
class AlreadyRegistered < StandardError; end
|
|
25
|
-
class InvalidDisplayUnit < StandardError; end
|
|
26
24
|
class NoDefaultRate < StandardError; end
|
|
27
25
|
class RegistryLocked < StandardError; end
|
|
26
|
+
class AlreadyRegistered < StandardError; end
|
|
27
|
+
class InvalidDisplayUnit < StandardError; end
|
|
28
28
|
|
|
29
29
|
Entry = Struct.new(
|
|
30
|
-
:symbol,
|
|
31
|
-
:
|
|
30
|
+
:symbol,
|
|
31
|
+
:decimals,
|
|
32
|
+
:display_symbol,
|
|
33
|
+
:display_position,
|
|
34
|
+
:ui_decimals,
|
|
35
|
+
:display_units,
|
|
36
|
+
:default_display,
|
|
37
|
+
:amount_class,
|
|
32
38
|
keyword_init: true
|
|
33
39
|
)
|
|
34
40
|
|
|
35
41
|
def initialize
|
|
36
|
-
@entries
|
|
42
|
+
@entries = {}
|
|
37
43
|
@default_rates = {}
|
|
38
|
-
@
|
|
39
|
-
|
|
44
|
+
@locked = false
|
|
45
|
+
|
|
40
46
|
@lock = Mutex.new
|
|
47
|
+
@generated_constructors = GeneratedConstructors.new
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
# Registers a new fungible type.
|
|
@@ -67,6 +74,8 @@ class Amount
|
|
|
67
74
|
def register(symbol, decimals:, display_symbol: symbol.to_s, display_position: :suffix,
|
|
68
75
|
ui_decimals: decimals, display_units: nil, default_display: nil,
|
|
69
76
|
class: nil)
|
|
77
|
+
raise ArgumentError, "symbol must not be blank" if symbol.nil? || symbol.to_s.empty?
|
|
78
|
+
|
|
70
79
|
symbol = symbol.to_sym
|
|
71
80
|
|
|
72
81
|
@lock.synchronize do
|
|
@@ -151,7 +160,7 @@ class Amount
|
|
|
151
160
|
|
|
152
161
|
@lock.synchronize do
|
|
153
162
|
ensure_unlocked!
|
|
154
|
-
@default_rates[[from, to]] =
|
|
163
|
+
@default_rates[[from, to]] = Amount.coerce_decimal(rate)
|
|
155
164
|
end
|
|
156
165
|
end
|
|
157
166
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
module RSpec
|
|
5
|
+
# Internal matcher helpers for the opt-in RSpec integration.
|
|
6
|
+
#
|
|
7
|
+
# All bare `RSpec` references inside this module are fully qualified to
|
|
8
|
+
# `::RSpec` to dodge the constant-lookup ambiguity introduced by living
|
|
9
|
+
# under `Amount::RSpec`.
|
|
10
|
+
module Matchers
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def define_amount_equality_matcher(name, &expected_builder)
|
|
14
|
+
::RSpec::Matchers.define name do |*arguments|
|
|
15
|
+
match do |actual|
|
|
16
|
+
@expected = instance_exec(*arguments, &expected_builder)
|
|
17
|
+
actual.is_a?(Amount) && actual == @expected
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
failure_message do |actual|
|
|
21
|
+
return "expected #{actual.inspect} to be an Amount equal to #{@expected.inspect}" unless actual.is_a?(Amount)
|
|
22
|
+
|
|
23
|
+
"expected #{actual.inspect} to equal amount #{@expected.inspect}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def define_amount_predicate_matcher(name, description, &predicate)
|
|
29
|
+
::RSpec::Matchers.define name do
|
|
30
|
+
match do |actual|
|
|
31
|
+
actual.is_a?(Amount) && instance_exec(actual, &predicate)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
failure_message do |actual|
|
|
35
|
+
"expected #{actual.inspect} to be #{description}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def define_amount_type_matcher
|
|
41
|
+
::RSpec::Matchers.define :be_amount_of do |expected_symbol|
|
|
42
|
+
match do |actual|
|
|
43
|
+
@expected_symbol = expected_symbol.to_sym
|
|
44
|
+
actual.is_a?(Amount) && actual.symbol == @expected_symbol
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
failure_message do |actual|
|
|
48
|
+
"expected #{actual.inspect} to be an Amount of #{@expected_symbol}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def define_approximate_amount_matcher
|
|
54
|
+
::RSpec::Matchers.define :be_approximately_amount do |*expected_arguments, within:|
|
|
55
|
+
match do |actual|
|
|
56
|
+
@expected = Amount::RSpec::Support.coerce_amount_arguments(expected_arguments)
|
|
57
|
+
@within = Amount::RSpec::Support.coerce_delta(@expected, within)
|
|
58
|
+
|
|
59
|
+
actual.is_a?(Amount) &&
|
|
60
|
+
actual.same_type?(@expected) &&
|
|
61
|
+
@within.same_type?(@expected) &&
|
|
62
|
+
(actual - @expected).abs.atomic <= @within.atomic
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
failure_message do |actual|
|
|
66
|
+
return "expected #{actual.inspect} to be an Amount within #{@within.inspect} of #{@expected.inspect}" unless actual.is_a?(Amount)
|
|
67
|
+
|
|
68
|
+
"expected #{actual.inspect} to be within #{@within.inspect} of #{@expected.inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
module RSpec
|
|
5
|
+
# Shared coercion helpers for opt-in RSpec integrations.
|
|
6
|
+
module Support
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def coerce_amount_arguments(arguments)
|
|
10
|
+
case arguments.length
|
|
11
|
+
when 1
|
|
12
|
+
coerce_amount(arguments.first)
|
|
13
|
+
when 2
|
|
14
|
+
Amount.new(arguments.last, arguments.first)
|
|
15
|
+
else
|
|
16
|
+
raise ArgumentError, "expected an Amount, a parse string, or a symbol/value pair"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def coerce_amount(value)
|
|
21
|
+
case value
|
|
22
|
+
when Amount then value
|
|
23
|
+
when String then Amount.parse(value)
|
|
24
|
+
when Hash then Amount.load(value)
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError, "cannot coerce #{value.inspect} into an Amount"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def coerce_delta(expected_amount, within)
|
|
31
|
+
case within
|
|
32
|
+
when Amount
|
|
33
|
+
within
|
|
34
|
+
when Integer, Float, BigDecimal, Rational, String
|
|
35
|
+
Amount.new(within, expected_amount.symbol)
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "cannot coerce #{within.inspect} into an amount delta"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_amount_sums(sum_hash)
|
|
42
|
+
sum_hash.to_h do |symbol, atomic|
|
|
43
|
+
amount = Amount.new(atomic, symbol, from: :atomic)
|
|
44
|
+
[amount.symbol, amount]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/amount/rspec.rb
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../amount"
|
|
4
4
|
require "rspec/expectations"
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "
|
|
5
|
+
require_relative "rspec/support"
|
|
6
|
+
require_relative "rspec/matchers"
|
|
7
7
|
|
|
8
8
|
# Opt-in RSpec matchers for `Amount`.
|
|
9
9
|
#
|
|
@@ -16,12 +16,12 @@ require_relative "rspec_matchers"
|
|
|
16
16
|
# expect(Amount.usdc("1.50")).to be_amount_of(:USDC)
|
|
17
17
|
# expect(Amount.usdc("1.50")).to be_positive_amount
|
|
18
18
|
# expect(Amount.usdc("1.55")).to be_approximately_amount(:USDC, "1.50", within: "0.10")
|
|
19
|
-
Amount::
|
|
20
|
-
Amount::
|
|
19
|
+
Amount::RSpec::Matchers.define_amount_equality_matcher(:eq_amount) do |*expected_arguments|
|
|
20
|
+
Amount::RSpec::Support.coerce_amount_arguments(expected_arguments)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
Amount::
|
|
24
|
-
Amount::
|
|
25
|
-
Amount::
|
|
26
|
-
Amount::
|
|
27
|
-
Amount::
|
|
23
|
+
Amount::RSpec::Matchers.define_amount_type_matcher
|
|
24
|
+
Amount::RSpec::Matchers.define_approximate_amount_matcher
|
|
25
|
+
Amount::RSpec::Matchers.define_amount_predicate_matcher(:be_zero_amount, "a zero Amount", &:zero?)
|
|
26
|
+
Amount::RSpec::Matchers.define_amount_predicate_matcher(:be_positive_amount, "a positive Amount", &:positive?)
|
|
27
|
+
Amount::RSpec::Matchers.define_amount_predicate_matcher(:be_negative_amount, "a negative Amount", &:negative?)
|