Class: Amount

Inherits:
Object
  • 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.

Examples:

Constructing from a UI value

Amount.register :USDC, decimals: 6

Amount.usdc("1.50").atomic
# => 1500000

Constructing from an atomic value

Amount.usdc(1_500_000, from: :atomic).decimal.to_s("F")
# => "1.5"

Defined Under Namespace

Modules: ActiveRecord, RSpecMatchers, RSpecSupport Classes: Display, Error, InvalidInput, Parser, Registry, Serializer, TypeMismatch, UnregisteredType

Constant Summary collapse

VERSION =
"0.0.1"

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

Examples:

Integer inputs are atomic by default

Amount.new(1_500_000, :USDC).decimal.to_s("F")
# => "1.5"

String inputs are UI values by default

Amount.new("1.50", :USDC).atomic
# => 1500000

Parameters:

  • value (Integer, String, Float, BigDecimal, Rational)
  • symbol (Symbol, String)

    registered type identifier

  • from (Symbol, nil) (defaults to: nil)

    one of ‘:atomic`, `:ui`, or `:float`

Raises:



138
139
140
141
142
143
144
145
146
147
# File 'lib/amount.rb', line 138

def initialize(value, symbol, from: nil)
  @symbol = symbol.to_sym
  @entry = self.class.registry.lookup(@symbol)

  if @entry.amount_class != self.class && @entry.amount_class != Amount
    raise InvalidInput, "use #{@entry.amount_class}.new for #{@symbol}" unless instance_of?(@entry.amount_class)
  end

  @atomic = infer_value(from, value)
end

Instance Attribute Details

#atomicObject (readonly)

Returns the value of attribute atomic.



116
117
118
# File 'lib/amount.rb', line 116

def atomic
  @atomic
end

#symbolObject (readonly)

Returns the value of attribute symbol.



116
117
118
# File 'lib/amount.rb', line 116

def symbol
  @symbol
end

Class Method Details

.load(hash) ⇒ Amount

Examples:

Loading the current versioned payload

Amount.load(v: 1, atomic: "1500000", symbol: "USDC")

Loading the legacy unversioned payload

Amount.load(atomic: 1500000, symbol: :USDC)

Parameters:

  • hash (Hash)

Returns:

Raises:



417
418
419
# File 'lib/amount.rb', line 417

def self.load(hash)
  Serializer.load(hash)
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`.

Examples:

Parsing the default compact format

Amount.parse("USDC|1.50")

Parsing the explicit versioned compact format

Amount.parse("v1:USDC|1.50")

Parameters:

  • str (String)

Returns:

Raises:



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.

Examples:

Registering a type

Amount.register :USDC,
  decimals: 6,
  display_symbol: "$",
  display_position: :prefix,
  ui_decimals: 2

Parameters:

  • symbol (Symbol, String)
  • opts (Hash)


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.

Examples:

Registering a directional default rate

Amount.register_default_rate :USD, :USDC, "1"

Parameters:

  • from (Symbol, String)
  • to (Symbol, String)
  • rate (String, Numeric, BigDecimal)


66
67
68
# File 'lib/amount.rb', line 66

def register_default_rate(from, to, rate)
  registry.register_default_rate(from, to, rate)
end

.registryAmount::Registry

Examples:

Accessing the shared registry

Amount.registry.locked?
# => false

Returns:



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.

Examples:

Using a temporary registry

test_registry = Amount::Registry.new
Amount.with_registry(test_registry) do
  Amount.register :TEST, decimals: 2
end

Parameters:

Yields:

Returns:

  • (Object)


97
98
99
100
101
102
103
# File 'lib/amount.rb', line 97

def with_registry(registry)
  original = Amount.instance_variable_get(:@registry)
  replace_registry(registry)
  yield
ensure
  replace_registry(original)
end

Instance Method Details

#*(scalar) ⇒ Amount

Examples:

Amount.usdc("1.25") * 2

Parameters:

  • scalar (Numeric)

Returns:

Raises:



263
264
265
266
# File 'lib/amount.rb', line 263

def *(scalar)
  ensure_scalar!(scalar)
  build((BigDecimal(@atomic) * BigDecimal(scalar.to_s)).to_i)
end

#+(other) ⇒ Amount

Examples:

Same-type addition

Amount.usdc("1.50") + Amount.usdc("0.50")

Cross-type addition using a registered directional rate

Amount.register_default_rate :USD, :USDC, "1"
Amount.usdc("10.00") + Amount.new("5.00", :USD)

Parameters:

Returns:

Raises:



243
244
245
246
# File 'lib/amount.rb', line 243

def +(other)
  rhs = coerce_other_to_self_type!(other)
  build(@atomic + rhs.atomic)
end

#-(other) ⇒ Amount

Examples:

Amount.usdc("2.00") - Amount.usdc("0.50")

Parameters:

Returns:

Raises:



253
254
255
256
# File 'lib/amount.rb', line 253

def -(other)
  rhs = coerce_other_to_self_type!(other)
  build(@atomic - rhs.atomic)
end

#-@Amount

Examples:

-Amount.usdc("1")
# => #<Amount USDC -$1.00>

Returns:



230
231
232
# File 'lib/amount.rb', line 230

def -@
  build(-@atomic)
end

#/(other) ⇒ Amount, BigDecimal

Examples:

Dividing by a scalar returns an amount

Amount.usdc("1.00") / 2

Dividing by an amount returns a ratio

Amount.usdc("10.00") / Amount.usdc("2.00")

Parameters:

Returns:

Raises:



276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/amount.rb', line 276

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) / BigDecimal(other.to_s)).to_i)
  end
end

#<=>(other) ⇒ -1, ...

Examples:

Amount.usdc("1") <=> Amount.usdc("2")
# => -1

Parameters:

  • other (Object)

Returns:

  • (-1, 0, 1, nil)


343
344
345
346
347
348
349
350
# File 'lib/amount.rb', line 343

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

Examples:

Amount.usdc("1.50") == Amount.usdc("1.50")
# => true

Parameters:

  • other (Object)

Returns:

  • (Boolean)


357
358
359
# File 'lib/amount.rb', line 357

def ==(other)
  same_type?(other) && @atomic == other.atomic
end

#absAmount

Examples:

Amount.usdc("-1").abs
# => #<Amount USDC $1.00>

Returns:



222
223
224
# File 'lib/amount.rb', line 222

def abs
  build(@atomic.abs)
end

#allocate(weights) ⇒ Array<(Array<Amount>, Amount)>

Allocates proportionally by integer weights and returns the leftover explicitly.

Examples:

parts, remainder = Amount.new(10, :LOGS).allocate([1, 1, 2])
parts.map(&:atomic)
# => [2, 2, 5]
remainder.atomic
# => 1

Parameters:

  • weights (Array<Integer>)

Returns:

Raises:

  • (ArgumentError)

    if weights are empty, negative, or sum to zero



322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/amount.rb', line 322

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

#decimalBigDecimal

Examples:

Converting the atomic value back to a decimal quantity

Amount.usdc(1_500_000, from: :atomic).decimal.to_s("F")
# => "1.5"

Returns:

  • (BigDecimal)


169
170
171
# File 'lib/amount.rb', line 169

def decimal
  BigDecimal(@atomic) / (BigDecimal(10)**decimals)
end

#decimalsInteger

Examples:

Reading the registered storage precision

Amount.usdc("1").decimals
# => 6

Returns:

  • (Integer)


161
162
163
# File 'lib/amount.rb', line 161

def decimals
  @entry.decimals
end

#displayAmount::Display

Examples:

Delegating formatting concerns

Amount.usdc("1.50").display.ui
# => "$1.50"

Returns:



177
178
179
# File 'lib/amount.rb', line 177

def display
  @display ||= Display.new(self)
end

#eql?(other) ⇒ Boolean

Examples:

Hash-key equality keeps class and symbol identity

Amount.usdc("1").eql?(Amount.usdc("1"))
# => true

Parameters:

  • other (Object)

Returns:

  • (Boolean)


366
367
368
# File 'lib/amount.rb', line 366

def eql?(other)
  other.class == self.class && symbol == other.symbol && @atomic == other.atomic
end

#hashInteger

Examples:

{ Amount.usdc("1") => :ok }[Amount.usdc("1")]
# => :ok

Returns:

  • (Integer)


374
375
376
# File 'lib/amount.rb', line 374

def hash
  [self.class, symbol, @atomic].hash
end

#inspectString

Examples:

Console-friendly inspection

Amount.usdc("1.50").inspect
# => "#<Amount USDC $1.50>"

Returns:

  • (String)


187
188
189
# File 'lib/amount.rb', line 187

def inspect
  "#<#{self.class} #{symbol} #{ui}>"
end

#negative?Boolean

Examples:

Amount.usdc("-1").negative?
# => true

Returns:

  • (Boolean)


207
# File 'lib/amount.rb', line 207

def negative? = @atomic.negative?

#positive?Boolean

Examples:

Amount.usdc("1").positive?
# => true

Returns:

  • (Boolean)


201
# File 'lib/amount.rb', line 201

def positive? = @atomic.positive?

#registry_entryAmount::Registry::Entry

Examples:

Accessing display configuration for this amount

Amount.usdc("1").registry_entry.ui_decimals
# => 2

Returns:



153
154
155
# File 'lib/amount.rb', line 153

def registry_entry
  @entry
end

#same_type?(other) ⇒ Boolean

Examples:

Amount.usdc("1").same_type?(Amount.usdc("2"))
# => true

Parameters:

  • other (Object)

Returns:

  • (Boolean)


214
215
216
# File 'lib/amount.rb', line 214

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.

Examples:

parts, remainder = Amount.new(10, :LOGS).split(3)
parts.map(&:atomic)
# => [3, 3, 3]
remainder.atomic
# => 1

Parameters:

  • n (Integer)

Returns:

Raises:

  • (ArgumentError)

    if ‘n` is not a positive integer



301
302
303
304
305
306
307
308
309
# File 'lib/amount.rb', line 301

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

Examples:

Using an explicit one-off rate

Amount.usdc("100").to(:GOLD, rate: "0.00042")

Using a registered default rate

Amount.register_default_rate :USDC, :USD, "1"
Amount.usdc("1.50").to(:USD)

Parameters:

  • target_symbol (Symbol, String)
  • rate (String, Numeric, BigDecimal, nil) (defaults to: nil)

Returns:

Raises:



388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/amount.rb', line 388

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 * BigDecimal(rate.to_s)
  atomic_result = (decimal_result * (BigDecimal(10)**target_entry.decimals)).to_i

  target_entry.amount_class.new(atomic_result, target_symbol, from: :atomic)
end

#to_hHash

Examples:

Amount.usdc("1.50").to_h
# => { v: 1, atomic: "1500000", symbol: "USDC" }

Returns:

  • (Hash)


405
406
407
# File 'lib/amount.rb', line 405

def to_h
  Serializer.dump(self)
end

#zero?Boolean

Examples:

Amount.usdc(0, from: :atomic).zero?
# => true

Returns:

  • (Boolean)


195
# File 'lib/amount.rb', line 195

def zero? = @atomic.zero?