Class: Amount
- Inherits:
-
Object
show all
- Extended by:
- Forwardable
- Includes:
- Comparable
- Defined in:
- lib/amount.rb,
lib/amount/parser.rb,
lib/amount/display.rb,
lib/amount/version.rb,
lib/amount/registry.rb,
lib/amount/serializer.rb,
lib/amount/active_record.rb,
lib/amount/rspec_support.rb,
lib/amount/rspec_matchers.rb,
lib/amount/active_record/type.rb,
lib/amount/active_record/model.rb,
lib/amount/active_record/amount_validator.rb,
lib/amount/active_record/migration_methods.rb,
lib/amount/registry/generated_constructors.rb,
lib/amount/active_record/attribute_definition.rb
Overview
Represents a precise quantity of a registered fungible type.
‘Amount` stores its value as an arbitrary-precision atomic `Integer` in the smallest unit configured for the registered symbol. UI values are parsed from strings or decimals, while integer inputs are treated as atomic counts unless `from:` overrides inference.
Defined Under Namespace
Modules: ActiveRecord, RSpecMatchers, RSpecSupport
Classes: Display, Error, InvalidInput, Parser, Registry, Serializer, TypeMismatch, UnregisteredType
Constant Summary
collapse
- VERSION =
"0.0.3"
Instance Attribute Summary collapse
Class Method Summary
collapse
Instance Method Summary
collapse
Constructor Details
#initialize(value, symbol, from: nil) ⇒ Amount
Creates an amount for a registered symbol.
Input inference rules:
-
‘Integer` => atomic units
-
‘String` => UI decimal value
-
‘Float`, `BigDecimal`, `Rational` => UI decimal value
-
‘from:` overrides inference explicitly
172
173
174
175
176
177
178
179
180
181
182
|
# File 'lib/amount.rb', line 172
def initialize(value, symbol, from: nil)
@symbol = symbol.to_sym
@entry = self.class.registry.lookup(@symbol)
expected = @entry.amount_class
if expected && expected != Amount && self.class != Amount && !instance_of?(expected)
raise InvalidInput, "use #{expected}.new for #{@symbol}"
end
@atomic = infer_value(from, value)
end
|
Instance Attribute Details
#atomic ⇒ Object
Returns the value of attribute atomic.
150
151
152
|
# File 'lib/amount.rb', line 150
def atomic
@atomic
end
|
#symbol ⇒ Object
Returns the value of attribute symbol.
150
151
152
|
# File 'lib/amount.rb', line 150
def symbol
@symbol
end
|
Class Method Details
.coerce_decimal(value) ⇒ BigDecimal
Coerces a numeric input to BigDecimal in a way that preserves Rational values. ‘BigDecimal(value.to_s)` raises `ArgumentError` for Rational because `Rational#to_s` produces strings like `“3/2”`. This helper is the single place every call site should use to convert a scalar / rate / display-unit scale into a BigDecimal.
113
114
115
116
117
118
119
|
# File 'lib/amount.rb', line 113
def coerce_decimal(value)
case value
when BigDecimal then value
when Rational then BigDecimal(value, Float::DIG + 4)
else BigDecimal(value.to_s)
end
end
|
.load(hash) ⇒ Amount
452
453
454
|
# File 'lib/amount.rb', line 452
def self.load(hash)
Serializer.load(hash)
end
|
.new(value, symbol, from: nil) ⇒ Amount
When called as ‘Amount.new` for a symbol whose registry entry binds a custom class, dispatch the construction to that class instead of raising. Direct calls to a subclass (`GoldAmount.new(…)`) still go through the default `Class.new` path. Calls that target the wrong subclass continue to raise from `#initialize`.
97
98
99
100
101
102
103
|
# File 'lib/amount.rb', line 97
def new(value, symbol, from: nil)
if equal?(::Amount)
entry_class = registry.lookup(symbol.to_sym).amount_class
return entry_class.new(value, symbol, from:) if entry_class && entry_class != ::Amount
end
super
end
|
.parse(str) ⇒ Amount
Parses the compact client-facing string representation.
Accepts either the default form ‘SYMBOL|amount` or the explicit versioned form `v1:SYMBOL|amount`.
83
84
85
|
# File 'lib/amount.rb', line 83
def parse(str)
Parser.new(str).parse
end
|
.register(symbol, **opts) ⇒ void
This method returns an undefined value.
56
57
58
|
# File 'lib/amount.rb', line 56
def register(symbol, **opts)
registry.register(symbol, **opts)
end
|
.register_default_rate(from, to, rate) ⇒ void
This method returns an undefined value.
66
67
68
|
# File 'lib/amount.rb', line 66
def register_default_rate(from, to, rate)
registry.register_default_rate(from, to, rate)
end
|
42
43
44
45
|
# File 'lib/amount.rb', line 42
def registry
Amount.instance_variable_get(:@registry) ||
replace_registry(Registry.new)
end
|
.with_registry(registry) { ... } ⇒ Object
Temporarily swaps the global registry. Intended for tests.
131
132
133
134
135
136
137
|
# File 'lib/amount.rb', line 131
def with_registry(registry)
original = Amount.instance_variable_get(:@registry)
replace_registry(registry)
yield
ensure
replace_registry(original)
end
|
Instance Method Details
#*(scalar) ⇒ Amount
298
299
300
301
|
# File 'lib/amount.rb', line 298
def *(scalar)
ensure_scalar!(scalar)
build((BigDecimal(@atomic) * Amount.coerce_decimal(scalar)).to_i)
end
|
278
279
280
281
|
# File 'lib/amount.rb', line 278
def +(other)
rhs = coerce_other_to_self_type!(other)
build(@atomic + rhs.atomic)
end
|
288
289
290
291
|
# File 'lib/amount.rb', line 288
def -(other)
rhs = coerce_other_to_self_type!(other)
build(@atomic - rhs.atomic)
end
|
265
266
267
|
# File 'lib/amount.rb', line 265
def -@
build(-@atomic)
end
|
#/(other) ⇒ Amount, BigDecimal
311
312
313
314
315
316
317
318
319
320
321
322
323
|
# File 'lib/amount.rb', line 311
def /(other)
if other.is_a?(Amount)
ensure_same_type!(other)
raise ZeroDivisionError if other.zero?
BigDecimal(@atomic) / BigDecimal(other.atomic)
else
ensure_scalar!(other)
raise ZeroDivisionError if other.zero?
build((BigDecimal(@atomic) / Amount.coerce_decimal(other)).to_i)
end
end
|
#<=>(other) ⇒ -1, ...
378
379
380
381
382
383
384
385
|
# File 'lib/amount.rb', line 378
def <=>(other)
return nil unless other.is_a?(Amount)
comparable = coerce_other_to_self_type(other)
return nil unless comparable
@atomic <=> comparable.atomic
end
|
#==(other) ⇒ Boolean
392
393
394
|
# File 'lib/amount.rb', line 392
def ==(other)
same_type?(other) && @atomic == other.atomic
end
|
257
258
259
|
# File 'lib/amount.rb', line 257
def abs
build(@atomic.abs)
end
|
#allocate(weights) ⇒ Array<(Array<Amount>, Amount)>
Allocates proportionally by integer weights and returns the leftover explicitly.
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
|
# File 'lib/amount.rb', line 357
def allocate(weights)
raise ArgumentError, "weights must be non-empty" if weights.empty?
raise ArgumentError, "weights must be non-negative integers" unless weights.all? { |weight| weight.is_a?(Integer) && weight >= 0 }
total = weights.sum
raise ArgumentError, "weights must sum to positive value" unless total.positive?
sign = atomic_sign
absolute_atomic = @atomic.abs
allocations = weights.map { |weight| absolute_atomic * weight / total }
remainder = absolute_atomic - allocations.sum
parts = allocations.map { |allocation| build(sign * allocation) }
[parts, build(sign * remainder)]
end
|
#decimal ⇒ BigDecimal
204
205
206
|
# File 'lib/amount.rb', line 204
def decimal
BigDecimal(@atomic) / (BigDecimal(10)**decimals)
end
|
#decimals ⇒ Integer
196
197
198
|
# File 'lib/amount.rb', line 196
def decimals
@entry.decimals
end
|
212
213
214
|
# File 'lib/amount.rb', line 212
def display
@display ||= Display.new(self)
end
|
#eql?(other) ⇒ Boolean
401
402
403
|
# File 'lib/amount.rb', line 401
def eql?(other)
other.class == self.class && symbol == other.symbol && @atomic == other.atomic
end
|
#hash ⇒ Integer
409
410
411
|
# File 'lib/amount.rb', line 409
def hash
[self.class, symbol, @atomic].hash
end
|
#inspect ⇒ String
222
223
224
|
# File 'lib/amount.rb', line 222
def inspect
"#<#{self.class} #{symbol} #{ui}>"
end
|
#negative? ⇒ Boolean
242
|
# File 'lib/amount.rb', line 242
def negative? = @atomic.negative?
|
#positive? ⇒ Boolean
236
|
# File 'lib/amount.rb', line 236
def positive? = @atomic.positive?
|
188
189
190
|
# File 'lib/amount.rb', line 188
def registry_entry
@entry
end
|
#same_type?(other) ⇒ Boolean
249
250
251
|
# File 'lib/amount.rb', line 249
def same_type?(other)
other.is_a?(Amount) && other.symbol == symbol
end
|
#split(n) ⇒ Array<(Array<Amount>, Amount)>
Splits into equal parts and returns the leftover explicitly.
336
337
338
339
340
341
342
343
344
|
# File 'lib/amount.rb', line 336
def split(n)
raise ArgumentError, "n must be positive" unless n.is_a?(Integer) && n.positive?
sign = atomic_sign
base, remainder = @atomic.abs.divmod(n)
parts = Array.new(n) { build(sign * base) }
[parts, build(sign * remainder)]
end
|
#to(target_symbol, rate: nil) ⇒ Amount
423
424
425
426
427
428
429
430
431
432
433
434
|
# File 'lib/amount.rb', line 423
def to(target_symbol, rate: nil)
target_symbol = target_symbol.to_sym
return self.class.new(@atomic, symbol, from: :atomic) if target_symbol == symbol
rate = resolve_rate(target_symbol, rate)
target_entry = self.class.registry.lookup(target_symbol)
decimal_result = decimal * Amount.coerce_decimal(rate)
atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i
target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
end
|
#to_h ⇒ Hash
440
441
442
|
# File 'lib/amount.rb', line 440
def to_h
Serializer.dump(self)
end
|
#zero? ⇒ Boolean
230
|
# File 'lib/amount.rb', line 230
def zero? = @atomic.zero?
|