amounts 0.0.3 → 0.0.5
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 +55 -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 +23 -6
- 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 +44 -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: b83ca37936b92cf689a865d0173dafd36e354f5e058fab07983efa436796b5d6
|
|
4
|
+
data.tar.gz: 3071b5b4bb06f46511a8dc45ec8d0f888b0863d61cfdbda363b313d806fb2f8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 398502b610a4e35059307a3f2e895f119ba8456bdfeb22cf95a0976d37b21fcbbedc8f0ff201b90efa6faf5b9cb1f18e9dae2209cc838f8f6cc3929ea73d4d81
|
|
7
|
+
data.tar.gz: d26be5a7c85fd002ddc3435880d25bebae7b7a29007295b064efb2bcfe0eacb0d3959044c499db1621e7e8e1b238b311c546dc24c591e8ccfc02b61e4865e281
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.5 - 2026-04-26
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `Amount#ui(decorated: false)` returns the rounded UI value as a plain
|
|
8
|
+
numeric string without the `display_symbol` prefix or suffix. Useful when
|
|
9
|
+
the caller renders the currency label separately (e.g. in a column header
|
|
10
|
+
or chip). Composes with `unit:` and `direction:` — for example,
|
|
11
|
+
`Amount.gold("1").ui(unit: :gram, decorated: false)` returns `"31.10"`.
|
|
12
|
+
Default remains `decorated: true`, so existing callers see no change.
|
|
13
|
+
|
|
14
|
+
## 0.0.4 - 2026-04-26
|
|
15
|
+
|
|
16
|
+
### Changed (breaking)
|
|
17
|
+
|
|
18
|
+
- The opt-in RSpec integration moved into a dedicated `Amount::RSpec` namespace
|
|
19
|
+
with one file per concern. Constants and require paths changed:
|
|
20
|
+
|
|
21
|
+
| Old | New |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `Amount::RSpecMatchers` | `Amount::RSpec::Matchers` |
|
|
24
|
+
| `Amount::RSpecSupport` | `Amount::RSpec::Support` |
|
|
25
|
+
| `lib/amount/rspec_matchers.rb` | `lib/amount/rspec/matchers.rb` |
|
|
26
|
+
| `lib/amount/rspec_support.rb` | `lib/amount/rspec/support.rb` |
|
|
27
|
+
|
|
28
|
+
The ActiveRecord-specific matchers also live in their own file under the same
|
|
29
|
+
pattern (`Amount::ActiveRecord::RSpec::Matchers` in
|
|
30
|
+
`lib/amount/active_record/rspec/matchers.rb`). Top-level `require` paths
|
|
31
|
+
(`require "amount/rspec"` and `require "amount/active_record/rspec"`) are
|
|
32
|
+
unchanged.
|
|
33
|
+
|
|
34
|
+
Update any direct constant references; no backwards-compatibility aliases are
|
|
35
|
+
shipped.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- The `Amount` instance behavior is now composed from focused mixins instead
|
|
40
|
+
of all living in a single 500+ line file. New modules under `lib/amount/`:
|
|
41
|
+
`Arithmetic` (`+`, `-`, `*`, `/`, `abs`, `-@`),
|
|
42
|
+
`Comparison` (`<=>`, `==`, `eql?`, `hash`, `same_type?`, sign predicates),
|
|
43
|
+
`Conversion` (`to(target_symbol, rate:)`),
|
|
44
|
+
`Allocation` (`split`, `allocate`),
|
|
45
|
+
and `Serialization` (instance `to_h` plus `Serialization::ClassMethods.load`
|
|
46
|
+
auto-extended via the `included` hook).
|
|
47
|
+
Public API unchanged. Shared private helpers (`build`,
|
|
48
|
+
`coerce_other_to_self_type[!]`, `ensure_same_type!`, `infer_value`,
|
|
49
|
+
`infer_type`, `ui_to_atomic`) remain on the main `Amount` class so every
|
|
50
|
+
mixin can call them.
|
|
51
|
+
|
|
52
|
+
### Removed
|
|
53
|
+
|
|
54
|
+
- `Amount::Serializer` is gone. Its `dump`/`load` class methods moved into
|
|
55
|
+
`Amount::Serialization` (instance `to_h` plus `ClassMethods.load`).
|
|
56
|
+
`Amount.load(hash)` and `Amount#to_h` are unchanged.
|
|
57
|
+
|
|
3
58
|
## 0.0.3 - 2026-04-26
|
|
4
59
|
|
|
5
60
|
### 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
|
@@ -18,9 +18,21 @@ class Amount
|
|
|
18
18
|
|
|
19
19
|
# @param unit [Symbol, nil]
|
|
20
20
|
# @param direction [Symbol]
|
|
21
|
+
# @param decorated [Boolean] when `false`, omit the display symbol and
|
|
22
|
+
# return just the rounded number. Useful when the caller renders the
|
|
23
|
+
# currency label separately (e.g. in a column header or a chip).
|
|
21
24
|
# @return [String]
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
# @example
|
|
26
|
+
# Amount.usdc("1.50").ui # => "$1.50"
|
|
27
|
+
# Amount.usdc("1.50").ui(decorated: false) # => "1.50"
|
|
28
|
+
# Amount.gold("1").ui(unit: :gram) # => "31.10 g"
|
|
29
|
+
# Amount.gold("1").ui(unit: :gram, decorated: false) # => "31.10"
|
|
30
|
+
def ui(unit: nil, direction: :floor, decorated: true)
|
|
31
|
+
if unit
|
|
32
|
+
render_display_unit(unit, direction, decorated:)
|
|
33
|
+
else
|
|
34
|
+
render_default(direction, decorated:)
|
|
35
|
+
end
|
|
24
36
|
end
|
|
25
37
|
|
|
26
38
|
# @return [String]
|
|
@@ -37,19 +49,24 @@ class Amount
|
|
|
37
49
|
|
|
38
50
|
private
|
|
39
51
|
|
|
40
|
-
def render_default(direction)
|
|
52
|
+
def render_default(direction, decorated:)
|
|
41
53
|
rounded = round(@amount.decimal, @entry.ui_decimals, direction)
|
|
42
|
-
|
|
54
|
+
formatted = format("%.#{@entry.ui_decimals}f", rounded)
|
|
55
|
+
return formatted unless decorated
|
|
56
|
+
|
|
57
|
+
apply_symbol(formatted, @entry.display_symbol, @entry.display_position)
|
|
43
58
|
end
|
|
44
59
|
|
|
45
|
-
def render_display_unit(unit, direction)
|
|
60
|
+
def render_display_unit(unit, direction, decorated:)
|
|
46
61
|
spec = fetch_display_unit(unit)
|
|
47
62
|
scaled = @amount.decimal * Amount.coerce_decimal(spec[:scale])
|
|
48
63
|
decimals = spec[:ui_decimals] || @entry.ui_decimals
|
|
49
64
|
rounded = round(scaled, decimals, direction)
|
|
65
|
+
formatted = format("%.#{decimals}f", rounded)
|
|
66
|
+
return formatted unless decorated
|
|
50
67
|
|
|
51
68
|
apply_symbol(
|
|
52
|
-
|
|
69
|
+
formatted,
|
|
53
70
|
spec[:symbol] || @entry.display_symbol,
|
|
54
71
|
spec[:position] || @entry.display_position
|
|
55
72
|
)
|
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
|