amounts 0.0.3 → 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 +44 -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/parser.rb +1 -1
- data/lib/amount/registry.rb +16 -7
- 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 +33 -272
- data/test/test_amount.rb +26 -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 -37
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,49 @@
|
|
|
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
|
+
|
|
3
47
|
## 0.0.3 - 2026-04-26
|
|
4
48
|
|
|
5
49
|
### 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/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
|
|
@@ -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?)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Versioned hash serialization. `include Serialization` does both halves:
|
|
5
|
+
# the instance side (`#to_h`) is mixed in directly, and the class-level
|
|
6
|
+
# entry point (`Amount.load`) is auto-extended onto the including class
|
|
7
|
+
# via the `included` hook below. The compact-string format is the
|
|
8
|
+
# responsibility of {Amount::Parser}.
|
|
9
|
+
module Serialization
|
|
10
|
+
VERSION = 1
|
|
11
|
+
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend(ClassMethods)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Class-level methods automatically extended onto any class that does
|
|
17
|
+
# `include Serialization`.
|
|
18
|
+
module ClassMethods
|
|
19
|
+
# @param payload [Hash]
|
|
20
|
+
# @return [Amount]
|
|
21
|
+
# @raise [Amount::InvalidInput] for unsupported versions or missing keys
|
|
22
|
+
# @example
|
|
23
|
+
# Amount.load(v: 1, atomic: "1500000", symbol: "USDC")
|
|
24
|
+
def load(payload)
|
|
25
|
+
payload = payload.transform_keys(&:to_sym)
|
|
26
|
+
validate_serialization_version!(payload[:v])
|
|
27
|
+
|
|
28
|
+
Amount.new(payload.fetch(:atomic), payload.fetch(:symbol), from: :atomic)
|
|
29
|
+
rescue KeyError => e
|
|
30
|
+
raise Amount::InvalidInput, "amount payload missing key: #{e.key}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_serialization_version!(version)
|
|
36
|
+
return if version.nil? || version == VERSION
|
|
37
|
+
|
|
38
|
+
raise Amount::InvalidInput, "unsupported amount serialization version: #{version}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash]
|
|
43
|
+
# @example
|
|
44
|
+
# Amount.usdc("1.50").to_h
|
|
45
|
+
# # => { v: 1, atomic: "1500000", symbol: "USDC" }
|
|
46
|
+
def to_h
|
|
47
|
+
{
|
|
48
|
+
v: VERSION,
|
|
49
|
+
atomic: @atomic.to_s,
|
|
50
|
+
symbol: @symbol.to_s
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/amount/version.rb
CHANGED
data/lib/amount.rb
CHANGED
|
@@ -7,7 +7,11 @@ require_relative "amount/version"
|
|
|
7
7
|
require_relative "amount/registry"
|
|
8
8
|
require_relative "amount/display"
|
|
9
9
|
require_relative "amount/parser"
|
|
10
|
-
require_relative "amount/
|
|
10
|
+
require_relative "amount/arithmetic"
|
|
11
|
+
require_relative "amount/allocation"
|
|
12
|
+
require_relative "amount/comparison"
|
|
13
|
+
require_relative "amount/conversion"
|
|
14
|
+
require_relative "amount/serialization"
|
|
11
15
|
|
|
12
16
|
# Represents a precise quantity of a registered fungible type.
|
|
13
17
|
#
|
|
@@ -16,6 +20,13 @@ require_relative "amount/serializer"
|
|
|
16
20
|
# strings or decimals, while integer inputs are treated as atomic counts unless
|
|
17
21
|
# `from:` overrides inference.
|
|
18
22
|
#
|
|
23
|
+
# Behavior is composed from a set of focused mixins:
|
|
24
|
+
# - {Arithmetic} — `+`, `-`, `*`, `/`, `abs`, `-@`
|
|
25
|
+
# - {Comparison} — `<=>`, `==`, `eql?`, `hash`, `same_type?`, sign predicates
|
|
26
|
+
# - {Conversion} — `to(:SYMBOL, rate:)`
|
|
27
|
+
# - {Allocation} — `split(n)`, `allocate(weights)`
|
|
28
|
+
# - {Serialization} — `to_h`
|
|
29
|
+
#
|
|
19
30
|
# @example Constructing from a UI value
|
|
20
31
|
# Amount.register :USDC, decimals: 6
|
|
21
32
|
#
|
|
@@ -26,7 +37,12 @@ require_relative "amount/serializer"
|
|
|
26
37
|
# Amount.usdc(1_500_000, from: :atomic).decimal.to_s("F")
|
|
27
38
|
# # => "1.5"
|
|
28
39
|
class Amount
|
|
29
|
-
include
|
|
40
|
+
include Arithmetic
|
|
41
|
+
include Allocation
|
|
42
|
+
include Comparison
|
|
43
|
+
include Conversion
|
|
44
|
+
include Serialization
|
|
45
|
+
|
|
30
46
|
extend Forwardable
|
|
31
47
|
|
|
32
48
|
class Error < StandardError; end
|
|
@@ -147,7 +163,7 @@ class Amount
|
|
|
147
163
|
end
|
|
148
164
|
end
|
|
149
165
|
|
|
150
|
-
attr_reader :atomic, :symbol
|
|
166
|
+
attr_reader :atomic, :symbol, :display
|
|
151
167
|
|
|
152
168
|
# Creates an amount for a registered symbol.
|
|
153
169
|
#
|
|
@@ -179,6 +195,7 @@ class Amount
|
|
|
179
195
|
end
|
|
180
196
|
|
|
181
197
|
@atomic = infer_value(from, value)
|
|
198
|
+
@display = Display.new(self)
|
|
182
199
|
end
|
|
183
200
|
|
|
184
201
|
# @return [Amount::Registry::Entry]
|
|
@@ -205,14 +222,6 @@ class Amount
|
|
|
205
222
|
BigDecimal(@atomic) / (BigDecimal(10)**decimals)
|
|
206
223
|
end
|
|
207
224
|
|
|
208
|
-
# @return [Amount::Display]
|
|
209
|
-
# @example Delegating formatting concerns
|
|
210
|
-
# Amount.usdc("1.50").display.ui
|
|
211
|
-
# # => "$1.50"
|
|
212
|
-
def display
|
|
213
|
-
@display ||= Display.new(self)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
225
|
def_delegators :display, :formatted, :ui, :to_s, :in_unit
|
|
217
226
|
|
|
218
227
|
# @return [String]
|
|
@@ -223,266 +232,34 @@ class Amount
|
|
|
223
232
|
"#<#{self.class} #{symbol} #{ui}>"
|
|
224
233
|
end
|
|
225
234
|
|
|
226
|
-
# @return [Boolean]
|
|
227
|
-
# @example
|
|
228
|
-
# Amount.usdc(0, from: :atomic).zero?
|
|
229
|
-
# # => true
|
|
230
|
-
def zero? = @atomic.zero?
|
|
231
|
-
|
|
232
|
-
# @return [Boolean]
|
|
233
|
-
# @example
|
|
234
|
-
# Amount.usdc("1").positive?
|
|
235
|
-
# # => true
|
|
236
|
-
def positive? = @atomic.positive?
|
|
237
|
-
|
|
238
|
-
# @return [Boolean]
|
|
239
|
-
# @example
|
|
240
|
-
# Amount.usdc("-1").negative?
|
|
241
|
-
# # => true
|
|
242
|
-
def negative? = @atomic.negative?
|
|
243
|
-
|
|
244
|
-
# @param other [Object]
|
|
245
|
-
# @return [Boolean]
|
|
246
|
-
# @example
|
|
247
|
-
# Amount.usdc("1").same_type?(Amount.usdc("2"))
|
|
248
|
-
# # => true
|
|
249
|
-
def same_type?(other)
|
|
250
|
-
other.is_a?(Amount) && other.symbol == symbol
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
# @return [Amount]
|
|
254
|
-
# @example
|
|
255
|
-
# Amount.usdc("-1").abs
|
|
256
|
-
# # => #<Amount USDC $1.00>
|
|
257
|
-
def abs
|
|
258
|
-
build(@atomic.abs)
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
# @return [Amount]
|
|
262
|
-
# @example
|
|
263
|
-
# -Amount.usdc("1")
|
|
264
|
-
# # => #<Amount USDC -$1.00>
|
|
265
|
-
def -@
|
|
266
|
-
build(-@atomic)
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
# @param other [Amount]
|
|
270
|
-
# @return [Amount]
|
|
271
|
-
# @raise [TypeMismatch]
|
|
272
|
-
# @example Same-type addition
|
|
273
|
-
# Amount.usdc("1.50") + Amount.usdc("0.50")
|
|
274
|
-
#
|
|
275
|
-
# @example Cross-type addition using a registered directional rate
|
|
276
|
-
# Amount.register_default_rate :USD, :USDC, "1"
|
|
277
|
-
# Amount.usdc("10.00") + Amount.new("5.00", :USD)
|
|
278
|
-
def +(other)
|
|
279
|
-
rhs = coerce_other_to_self_type!(other)
|
|
280
|
-
build(@atomic + rhs.atomic)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
# @param other [Amount]
|
|
284
|
-
# @return [Amount]
|
|
285
|
-
# @raise [TypeMismatch]
|
|
286
|
-
# @example
|
|
287
|
-
# Amount.usdc("2.00") - Amount.usdc("0.50")
|
|
288
|
-
def -(other)
|
|
289
|
-
rhs = coerce_other_to_self_type!(other)
|
|
290
|
-
build(@atomic - rhs.atomic)
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
# @param scalar [Numeric]
|
|
294
|
-
# @return [Amount]
|
|
295
|
-
# @raise [TypeMismatch]
|
|
296
|
-
# @example
|
|
297
|
-
# Amount.usdc("1.25") * 2
|
|
298
|
-
def *(scalar)
|
|
299
|
-
ensure_scalar!(scalar)
|
|
300
|
-
build((BigDecimal(@atomic) * Amount.coerce_decimal(scalar)).to_i)
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# @param other [Amount, Numeric]
|
|
304
|
-
# @return [Amount, BigDecimal]
|
|
305
|
-
# @raise [TypeMismatch, ZeroDivisionError]
|
|
306
|
-
# @example Dividing by a scalar returns an amount
|
|
307
|
-
# Amount.usdc("1.00") / 2
|
|
308
|
-
#
|
|
309
|
-
# @example Dividing by an amount returns a ratio
|
|
310
|
-
# Amount.usdc("10.00") / Amount.usdc("2.00")
|
|
311
|
-
def /(other)
|
|
312
|
-
if other.is_a?(Amount)
|
|
313
|
-
ensure_same_type!(other)
|
|
314
|
-
raise ZeroDivisionError if other.zero?
|
|
315
|
-
|
|
316
|
-
BigDecimal(@atomic) / BigDecimal(other.atomic)
|
|
317
|
-
else
|
|
318
|
-
ensure_scalar!(other)
|
|
319
|
-
raise ZeroDivisionError if other.zero?
|
|
320
|
-
|
|
321
|
-
build((BigDecimal(@atomic) / Amount.coerce_decimal(other)).to_i)
|
|
322
|
-
end
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
# Splits into equal parts and returns the leftover explicitly.
|
|
326
|
-
#
|
|
327
|
-
# @param n [Integer]
|
|
328
|
-
# @return [Array<(Array<Amount>, Amount)>]
|
|
329
|
-
# @raise [ArgumentError] if `n` is not a positive integer
|
|
330
|
-
# @example
|
|
331
|
-
# parts, remainder = Amount.new(10, :LOGS).split(3)
|
|
332
|
-
# parts.map(&:atomic)
|
|
333
|
-
# # => [3, 3, 3]
|
|
334
|
-
# remainder.atomic
|
|
335
|
-
# # => 1
|
|
336
|
-
def split(n)
|
|
337
|
-
raise ArgumentError, "n must be positive" unless n.is_a?(Integer) && n.positive?
|
|
338
|
-
|
|
339
|
-
sign = atomic_sign
|
|
340
|
-
base, remainder = @atomic.abs.divmod(n)
|
|
341
|
-
parts = Array.new(n) { build(sign * base) }
|
|
342
|
-
|
|
343
|
-
[parts, build(sign * remainder)]
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
# Allocates proportionally by integer weights and returns the leftover explicitly.
|
|
347
|
-
#
|
|
348
|
-
# @param weights [Array<Integer>]
|
|
349
|
-
# @return [Array<(Array<Amount>, Amount)>]
|
|
350
|
-
# @raise [ArgumentError] if weights are empty, negative, or sum to zero
|
|
351
|
-
# @example
|
|
352
|
-
# parts, remainder = Amount.new(10, :LOGS).allocate([1, 1, 2])
|
|
353
|
-
# parts.map(&:atomic)
|
|
354
|
-
# # => [2, 2, 5]
|
|
355
|
-
# remainder.atomic
|
|
356
|
-
# # => 1
|
|
357
|
-
def allocate(weights)
|
|
358
|
-
raise ArgumentError, "weights must be non-empty" if weights.empty?
|
|
359
|
-
raise ArgumentError, "weights must be non-negative integers" unless weights.all? { |weight| weight.is_a?(Integer) && weight >= 0 }
|
|
360
|
-
|
|
361
|
-
total = weights.sum
|
|
362
|
-
raise ArgumentError, "weights must sum to positive value" unless total.positive?
|
|
363
|
-
|
|
364
|
-
sign = atomic_sign
|
|
365
|
-
absolute_atomic = @atomic.abs
|
|
366
|
-
allocations = weights.map { |weight| absolute_atomic * weight / total }
|
|
367
|
-
remainder = absolute_atomic - allocations.sum
|
|
368
|
-
|
|
369
|
-
parts = allocations.map { |allocation| build(sign * allocation) }
|
|
370
|
-
[parts, build(sign * remainder)]
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
# @param other [Object]
|
|
374
|
-
# @return [-1, 0, 1, nil]
|
|
375
|
-
# @example
|
|
376
|
-
# Amount.usdc("1") <=> Amount.usdc("2")
|
|
377
|
-
# # => -1
|
|
378
|
-
def <=>(other)
|
|
379
|
-
return nil unless other.is_a?(Amount)
|
|
380
|
-
|
|
381
|
-
comparable = coerce_other_to_self_type(other)
|
|
382
|
-
return nil unless comparable
|
|
383
|
-
|
|
384
|
-
@atomic <=> comparable.atomic
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
# @param other [Object]
|
|
388
|
-
# @return [Boolean]
|
|
389
|
-
# @example
|
|
390
|
-
# Amount.usdc("1.50") == Amount.usdc("1.50")
|
|
391
|
-
# # => true
|
|
392
|
-
def ==(other)
|
|
393
|
-
same_type?(other) && @atomic == other.atomic
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
# @param other [Object]
|
|
397
|
-
# @return [Boolean]
|
|
398
|
-
# @example Hash-key equality keeps class and symbol identity
|
|
399
|
-
# Amount.usdc("1").eql?(Amount.usdc("1"))
|
|
400
|
-
# # => true
|
|
401
|
-
def eql?(other)
|
|
402
|
-
other.class == self.class && symbol == other.symbol && @atomic == other.atomic
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
# @return [Integer]
|
|
406
|
-
# @example
|
|
407
|
-
# { Amount.usdc("1") => :ok }[Amount.usdc("1")]
|
|
408
|
-
# # => :ok
|
|
409
|
-
def hash
|
|
410
|
-
[self.class, symbol, @atomic].hash
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
# @param target_symbol [Symbol, String]
|
|
414
|
-
# @param rate [String, Numeric, BigDecimal, nil]
|
|
415
|
-
# @return [Amount]
|
|
416
|
-
# @raise [Amount::Registry::NoDefaultRate] if no explicit or registered rate is available
|
|
417
|
-
# @example Using an explicit one-off rate
|
|
418
|
-
# Amount.usdc("100").to(:GOLD, rate: "0.00042")
|
|
419
|
-
#
|
|
420
|
-
# @example Using a registered default rate
|
|
421
|
-
# Amount.register_default_rate :USDC, :USD, "1"
|
|
422
|
-
# Amount.usdc("1.50").to(:USD)
|
|
423
|
-
def to(target_symbol, rate: nil)
|
|
424
|
-
target_symbol = target_symbol.to_sym
|
|
425
|
-
return self.class.new(@atomic, symbol, from: :atomic) if target_symbol == symbol
|
|
426
|
-
|
|
427
|
-
rate = resolve_rate(target_symbol, rate)
|
|
428
|
-
target_entry = self.class.registry.lookup(target_symbol)
|
|
429
|
-
|
|
430
|
-
decimal_result = decimal * Amount.coerce_decimal(rate)
|
|
431
|
-
atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
|
|
432
|
-
|
|
433
|
-
target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
# @return [Hash]
|
|
437
|
-
# @example
|
|
438
|
-
# Amount.usdc("1.50").to_h
|
|
439
|
-
# # => { v: 1, atomic: "1500000", symbol: "USDC" }
|
|
440
|
-
def to_h
|
|
441
|
-
Serializer.dump(self)
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
# @param hash [Hash]
|
|
445
|
-
# @return [Amount]
|
|
446
|
-
# @raise [InvalidInput] for unsupported serialized versions
|
|
447
|
-
# @example Loading the current versioned payload
|
|
448
|
-
# Amount.load(v: 1, atomic: "1500000", symbol: "USDC")
|
|
449
|
-
#
|
|
450
|
-
# @example Loading the legacy unversioned payload
|
|
451
|
-
# Amount.load(atomic: 1500000, symbol: :USDC)
|
|
452
|
-
def self.load(hash)
|
|
453
|
-
Serializer.load(hash)
|
|
454
|
-
end
|
|
455
|
-
|
|
456
235
|
private
|
|
457
236
|
|
|
237
|
+
# Builds a same-symbol amount in the receiver's class. Used by every
|
|
238
|
+
# operator that returns an Amount so subclass identity propagates.
|
|
458
239
|
def build(atomic_value)
|
|
459
240
|
self.class.new(atomic_value, symbol, from: :atomic)
|
|
460
241
|
end
|
|
461
242
|
|
|
462
|
-
def atomic_sign
|
|
463
|
-
return 1 if @atomic.positive?
|
|
464
|
-
return(-1) if @atomic.negative?
|
|
465
|
-
|
|
466
|
-
0
|
|
467
|
-
end
|
|
468
|
-
|
|
469
243
|
def ensure_same_type!(other)
|
|
470
244
|
return if same_type?(other)
|
|
471
245
|
|
|
472
246
|
raise TypeMismatch, "type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
|
|
473
247
|
end
|
|
474
248
|
|
|
475
|
-
def
|
|
476
|
-
return if
|
|
477
|
-
|
|
249
|
+
def coerce_other_to_self_type(other)
|
|
250
|
+
return other if same_type?(other)
|
|
251
|
+
return unless other.is_a?(Amount)
|
|
478
252
|
|
|
479
|
-
|
|
253
|
+
other.to(symbol)
|
|
254
|
+
rescue Registry::NoDefaultRate
|
|
255
|
+
nil
|
|
480
256
|
end
|
|
481
257
|
|
|
482
|
-
def
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
258
|
+
def coerce_other_to_self_type!(other)
|
|
259
|
+
coerce_other_to_self_type(other) || raise(
|
|
260
|
+
TypeMismatch,
|
|
261
|
+
"type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
|
|
262
|
+
)
|
|
486
263
|
end
|
|
487
264
|
|
|
488
265
|
def infer_value(from, value)
|
|
@@ -512,20 +289,4 @@ class Amount
|
|
|
512
289
|
rescue ArgumentError
|
|
513
290
|
raise InvalidInput, "cannot parse #{value.inspect} as #{symbol}"
|
|
514
291
|
end
|
|
515
|
-
|
|
516
|
-
def coerce_other_to_self_type!(other)
|
|
517
|
-
coerce_other_to_self_type(other) || raise(
|
|
518
|
-
TypeMismatch,
|
|
519
|
-
"type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
|
|
520
|
-
)
|
|
521
|
-
end
|
|
522
|
-
|
|
523
|
-
def coerce_other_to_self_type(other)
|
|
524
|
-
return other if same_type?(other)
|
|
525
|
-
return unless other.is_a?(Amount)
|
|
526
|
-
|
|
527
|
-
other.to(symbol)
|
|
528
|
-
rescue Registry::NoDefaultRate
|
|
529
|
-
nil
|
|
530
|
-
end
|
|
531
292
|
end
|
data/test/test_amount.rb
CHANGED
|
@@ -126,6 +126,32 @@ class AmountTest < Minitest::Test
|
|
|
126
126
|
assert_raises(Amount::Registry::UnknownType) { Amount.new(1, :DOGE) }
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
+
def test_register_rejects_empty_symbol
|
|
130
|
+
error = assert_raises(ArgumentError) { Amount.register :"", decimals: 2 }
|
|
131
|
+
assert_match(/symbol must not be blank/, error.message)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def test_register_rejects_nil_symbol
|
|
135
|
+
assert_raises(ArgumentError) { Amount.register nil, decimals: 2 }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def test_frozen_amount_renders_display
|
|
139
|
+
amount = Amount.new("1.5", :USDC).freeze
|
|
140
|
+
|
|
141
|
+
assert_equal "$1.50", amount.ui
|
|
142
|
+
assert_equal "1.500000", amount.formatted
|
|
143
|
+
assert_equal "USDC|1.5", amount.to_s
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def test_frozen_amount_arithmetic_returns_unfrozen_result
|
|
147
|
+
a = Amount.new("1", :USDC).freeze
|
|
148
|
+
b = Amount.new("2", :USDC).freeze
|
|
149
|
+
sum = a + b
|
|
150
|
+
|
|
151
|
+
assert_equal Amount.new("3", :USDC), sum
|
|
152
|
+
refute sum.frozen?
|
|
153
|
+
end
|
|
154
|
+
|
|
129
155
|
def test_formatted_respects_storage_decimals
|
|
130
156
|
assert_equal "1.500000", Amount.new("1.5", :USDC).formatted
|
|
131
157
|
assert_equal "1.500000000", Amount.new("1.5", :SOL).formatted
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: amounts
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Seb Scholl
|
|
@@ -51,15 +51,20 @@ files:
|
|
|
51
51
|
- lib/amount/active_record/migration_methods.rb
|
|
52
52
|
- lib/amount/active_record/model.rb
|
|
53
53
|
- lib/amount/active_record/rspec.rb
|
|
54
|
+
- lib/amount/active_record/rspec/matchers.rb
|
|
54
55
|
- lib/amount/active_record/type.rb
|
|
56
|
+
- lib/amount/allocation.rb
|
|
57
|
+
- lib/amount/arithmetic.rb
|
|
58
|
+
- lib/amount/comparison.rb
|
|
59
|
+
- lib/amount/conversion.rb
|
|
55
60
|
- lib/amount/display.rb
|
|
56
61
|
- lib/amount/parser.rb
|
|
57
62
|
- lib/amount/registry.rb
|
|
58
63
|
- lib/amount/registry/generated_constructors.rb
|
|
59
64
|
- lib/amount/rspec.rb
|
|
60
|
-
- lib/amount/
|
|
61
|
-
- lib/amount/
|
|
62
|
-
- lib/amount/
|
|
65
|
+
- lib/amount/rspec/matchers.rb
|
|
66
|
+
- lib/amount/rspec/support.rb
|
|
67
|
+
- lib/amount/serialization.rb
|
|
63
68
|
- lib/amount/version.rb
|
|
64
69
|
- test/dummy/app/models/holding.rb
|
|
65
70
|
- test/dummy/bin/rails
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
class Amount
|
|
4
|
-
# Internal matcher helpers for the opt-in RSpec integration.
|
|
5
|
-
module RSpecMatchers
|
|
6
|
-
module_function
|
|
7
|
-
|
|
8
|
-
def define_amount_equality_matcher(name, &expected_builder)
|
|
9
|
-
RSpec::Matchers.define name do |*arguments|
|
|
10
|
-
match do |actual|
|
|
11
|
-
@expected = instance_exec(*arguments, &expected_builder)
|
|
12
|
-
actual.is_a?(Amount) && actual == @expected
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
failure_message do |actual|
|
|
16
|
-
return "expected #{actual.inspect} to be an Amount equal to #{@expected.inspect}" unless actual.is_a?(Amount)
|
|
17
|
-
|
|
18
|
-
"expected #{actual.inspect} to equal amount #{@expected.inspect}"
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def define_amount_predicate_matcher(name, description, &predicate)
|
|
24
|
-
RSpec::Matchers.define name do
|
|
25
|
-
match do |actual|
|
|
26
|
-
actual.is_a?(Amount) && instance_exec(actual, &predicate)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
failure_message do |actual|
|
|
30
|
-
"expected #{actual.inspect} to be #{description}"
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def define_amount_type_matcher
|
|
36
|
-
RSpec::Matchers.define :be_amount_of do |expected_symbol|
|
|
37
|
-
match do |actual|
|
|
38
|
-
@expected_symbol = expected_symbol.to_sym
|
|
39
|
-
actual.is_a?(Amount) && actual.symbol == @expected_symbol
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
failure_message do |actual|
|
|
43
|
-
"expected #{actual.inspect} to be an Amount of #{@expected_symbol}"
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def define_approximate_amount_matcher
|
|
49
|
-
RSpec::Matchers.define :be_approximately_amount do |*expected_arguments, within:|
|
|
50
|
-
match do |actual|
|
|
51
|
-
@expected = Amount::RSpecSupport.coerce_amount_arguments(expected_arguments)
|
|
52
|
-
@within = Amount::RSpecSupport.coerce_delta(@expected, within)
|
|
53
|
-
|
|
54
|
-
actual.is_a?(Amount) &&
|
|
55
|
-
actual.same_type?(@expected) &&
|
|
56
|
-
@within.same_type?(@expected) &&
|
|
57
|
-
(actual - @expected).abs.atomic <= @within.atomic
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
failure_message do |actual|
|
|
61
|
-
return "expected #{actual.inspect} to be an Amount within #{@within.inspect} of #{@expected.inspect}" unless actual.is_a?(Amount)
|
|
62
|
-
|
|
63
|
-
"expected #{actual.inspect} to be within #{@within.inspect} of #{@expected.inspect}"
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def define_amount_column_matcher
|
|
69
|
-
RSpec::Matchers.define :have_amount_column do |name, *expected_arguments|
|
|
70
|
-
match do |record|
|
|
71
|
-
@name = name
|
|
72
|
-
@expected = Amount::RSpecSupport.coerce_amount_arguments(expected_arguments)
|
|
73
|
-
@definition = record.class.amount_attribute_definitions.fetch(name.to_sym)
|
|
74
|
-
|
|
75
|
-
@definition.read(record) == @expected &&
|
|
76
|
-
record.public_send(@definition.atomic_column).to_i == @expected.atomic &&
|
|
77
|
-
(@definition.fixed_symbol? ||
|
|
78
|
-
record.public_send(@definition.symbol_column) == @expected.symbol.to_s)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
failure_message do |record|
|
|
82
|
-
"expected #{record.inspect} to have #{@name} column matching #{@expected.inspect}"
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def define_amount_sum_matcher
|
|
88
|
-
RSpec::Matchers.define :match_amounts do |expected_hash|
|
|
89
|
-
match do |actual_hash|
|
|
90
|
-
@expected = expected_hash.to_h do |symbol, value|
|
|
91
|
-
amount = Amount.new(value, symbol)
|
|
92
|
-
[amount.symbol, amount]
|
|
93
|
-
end
|
|
94
|
-
@actual = Amount::RSpecSupport.normalize_amount_sums(actual_hash)
|
|
95
|
-
|
|
96
|
-
@actual == @expected
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
failure_message do |_actual_hash|
|
|
100
|
-
"expected grouped amounts #{@actual.inspect} to match #{@expected.inspect}"
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
data/lib/amount/rspec_support.rb
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
class Amount
|
|
4
|
-
# Shared coercion helpers for opt-in RSpec integrations.
|
|
5
|
-
module RSpecSupport
|
|
6
|
-
module_function
|
|
7
|
-
|
|
8
|
-
def coerce_amount_arguments(arguments)
|
|
9
|
-
case arguments.length
|
|
10
|
-
when 1
|
|
11
|
-
coerce_amount(arguments.first)
|
|
12
|
-
when 2
|
|
13
|
-
Amount.new(arguments.last, arguments.first)
|
|
14
|
-
else
|
|
15
|
-
raise ArgumentError, "expected an Amount, a parse string, or a symbol/value pair"
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def coerce_amount(value)
|
|
20
|
-
case value
|
|
21
|
-
when Amount then value
|
|
22
|
-
when String then Amount.parse(value)
|
|
23
|
-
when Hash then Amount.load(value)
|
|
24
|
-
else
|
|
25
|
-
raise ArgumentError, "cannot coerce #{value.inspect} into an Amount"
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def coerce_delta(expected_amount, within)
|
|
30
|
-
case within
|
|
31
|
-
when Amount
|
|
32
|
-
within
|
|
33
|
-
when Integer, Float, BigDecimal, Rational, String
|
|
34
|
-
Amount.new(within, expected_amount.symbol)
|
|
35
|
-
else
|
|
36
|
-
raise ArgumentError, "cannot coerce #{within.inspect} into an amount delta"
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def normalize_amount_sums(sum_hash)
|
|
41
|
-
sum_hash.to_h do |symbol, atomic|
|
|
42
|
-
amount = Amount.new(atomic, symbol, from: :atomic)
|
|
43
|
-
[amount.symbol, amount]
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
data/lib/amount/serializer.rb
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
class Amount
|
|
4
|
-
# Converts amounts to and from the versioned hash payload.
|
|
5
|
-
class Serializer
|
|
6
|
-
VERSION = 1
|
|
7
|
-
|
|
8
|
-
def self.dump(amount)
|
|
9
|
-
{
|
|
10
|
-
v: VERSION,
|
|
11
|
-
atomic: amount.atomic.to_s,
|
|
12
|
-
symbol: amount.symbol.to_s
|
|
13
|
-
}
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def self.load(payload)
|
|
17
|
-
version = payload[:v] || payload["v"]
|
|
18
|
-
validate_version!(version)
|
|
19
|
-
|
|
20
|
-
Amount.new(
|
|
21
|
-
payload.fetch(:atomic) { payload.fetch("atomic") },
|
|
22
|
-
payload.fetch(:symbol) { payload.fetch("symbol") },
|
|
23
|
-
from: :atomic
|
|
24
|
-
)
|
|
25
|
-
rescue KeyError => e
|
|
26
|
-
raise InvalidInput, "amount payload missing key: #{e.key}"
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def self.validate_version!(version)
|
|
30
|
-
return if version.nil? || version == VERSION
|
|
31
|
-
|
|
32
|
-
raise InvalidInput, "unsupported amount serialization version: #{version}"
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
private_class_method :validate_version!
|
|
36
|
-
end
|
|
37
|
-
end
|