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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95dbf17ecb45d86b36277c6ecdaf611bdcb99d12eb97a3fc42b5f3bf2cd1ec50
4
- data.tar.gz: 67fcceae2bae4582f4c4e2866f453727a23f444703e47717ab9e7420e8ff20de
3
+ metadata.gz: 8b7620b025be07f1166e655108c2b9cea0232acb60d814cf91b71ecb47ddf1fb
4
+ data.tar.gz: 8f8ab43608f4ced54ab5020c7cbcf69b0741c9203e549558b27356766187efb5
5
5
  SHA512:
6
- metadata.gz: 15833dd17e8b167cfc61812af65dc0addad29912459a3886df4bd54b01cd8c43d055654cd971838a1c9b5fc76bca8cf83e8eaa72e25eaa4901eef93177275e4a
7
- data.tar.gz: 002e0c5539b2ed22551a87d0d696a8cbfd2b87565b3c991a91fbbacb3c81e722d3b8b1af62bf1aa0604e08691805bfb2375cf2015f06bd57c62228525b9454ca
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::RSpecMatchers.define_amount_column_matcher
8
- Amount::RSpecMatchers.define_amount_sum_matcher
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
- if value.key?(:atomic) || value.key?("atomic")
80
+ value = value.transform_keys(&:to_sym)
81
+ if value.key?(:atomic)
81
82
  ::Amount.load(value)
82
83
  else
83
- symbol = value.fetch(:symbol) { value.fetch("symbol", fixed_symbol) }
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
@@ -3,8 +3,8 @@
3
3
  class Amount
4
4
  # Parses compact amount strings such as `USDC|1.50`.
5
5
  class Parser
6
- VERSION_PREFIX = /\A(v\d+):(.*)\z/
7
6
  SUPPORTED_VERSION = "v1"
7
+ VERSION_PREFIX = /\A(v\d+):(.*)\z/
8
8
 
9
9
  def initialize(input)
10
10
  @input = input
@@ -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, :decimals, :display_symbol, :display_position,
31
- :ui_decimals, :display_units, :default_display, :amount_class,
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
- @generated_constructors = GeneratedConstructors.new
39
- @locked = false
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 "rspec_support"
6
- require_relative "rspec_matchers"
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::RSpecMatchers.define_amount_equality_matcher(:eq_amount) do |*expected_arguments|
20
- Amount::RSpecSupport.coerce_amount_arguments(expected_arguments)
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::RSpecMatchers.define_amount_type_matcher
24
- Amount::RSpecMatchers.define_amount_predicate_matcher(:be_zero_amount, "a zero Amount", &:zero?)
25
- Amount::RSpecMatchers.define_amount_predicate_matcher(:be_positive_amount, "a positive Amount", &:positive?)
26
- Amount::RSpecMatchers.define_amount_predicate_matcher(:be_negative_amount, "a negative Amount", &:negative?)
27
- Amount::RSpecMatchers.define_approximate_amount_matcher
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Amount
4
- VERSION = "0.0.3"
4
+ VERSION = "0.0.4"
5
5
  end
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/serializer"
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 Comparable
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 ensure_scalar!(value)
476
- return if value.is_a?(Integer) || value.is_a?(Float) ||
477
- value.is_a?(BigDecimal) || value.is_a?(Rational)
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
- raise TypeMismatch, "expected scalar, got #{value.class}"
253
+ other.to(symbol)
254
+ rescue Registry::NoDefaultRate
255
+ nil
480
256
  end
481
257
 
482
- def resolve_rate(target, provided)
483
- return provided if provided
484
-
485
- self.class.registry.default_rate(symbol, target)
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.3
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/rspec_matchers.rb
61
- - lib/amount/rspec_support.rb
62
- - lib/amount/serializer.rb
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
@@ -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
@@ -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