amounts 0.0.7 → 0.1.0
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/.rubocop.yml +37 -0
- data/CHANGELOG.md +142 -1
- data/Gemfile +4 -0
- data/README.md +218 -4
- data/Rakefile +47 -10
- data/lib/amount/active_record/amount_validator.rb +9 -18
- data/lib/amount/active_record/attribute_definition.rb +3 -1
- data/lib/amount/active_record/locale/en.yml +7 -0
- data/lib/amount/active_record/model.rb +9 -0
- data/lib/amount/active_record/railtie.rb +1 -1
- data/lib/amount/active_record.rb +12 -0
- data/lib/amount/compact.rb +137 -0
- data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
- data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +9 -9
- data/lib/amount/concerns/coercible.rb +65 -0
- data/lib/amount/concerns/constructible.rb +92 -0
- data/lib/amount/{conversion.rb → concerns/convertible.rb} +3 -3
- data/lib/amount/concerns/formattable.rb +112 -0
- data/lib/amount/concerns/localizable.rb +39 -0
- data/lib/amount/{comparison.rb → concerns/orderable.rb} +10 -10
- data/lib/amount/concerns/parsable.rb +58 -0
- data/lib/amount/concerns/registrable.rb +80 -0
- data/lib/amount/{serialization.rb → concerns/serializable.rb} +4 -4
- data/lib/amount/display.rb +86 -51
- data/lib/amount/locale.rb +102 -0
- data/lib/amount/registry/generated_constructors.rb +10 -5
- data/lib/amount/registry.rb +72 -9
- data/lib/amount/rspec.rb +4 -4
- data/lib/amount/ui_parser.rb +109 -0
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +61 -205
- data/test/amount/active_record/amount_validator_test.rb +201 -0
- data/test/amount/active_record/attribute_definition_test.rb +234 -0
- data/test/amount/active_record/migration_methods_test.rb +60 -0
- data/test/amount/active_record/model_test.rb +40 -0
- data/test/amount/active_record/type_test.rb +80 -0
- data/test/amount/active_record_test.rb +12 -0
- data/test/amount/compact_test.rb +371 -0
- data/test/amount/concerns/allocatable_test.rb +166 -0
- data/test/amount/concerns/calculable_test.rb +152 -0
- data/test/amount/concerns/coercible_test.rb +49 -0
- data/test/amount/concerns/constructible_test.rb +113 -0
- data/test/amount/concerns/convertible_test.rb +54 -0
- data/test/amount/concerns/formattable_test.rb +336 -0
- data/test/amount/concerns/localizable_test.rb +59 -0
- data/test/amount/concerns/orderable_test.rb +83 -0
- data/test/amount/concerns/parsable_test.rb +98 -0
- data/test/amount/concerns/registrable_test.rb +67 -0
- data/test/amount/concerns/serializable_test.rb +98 -0
- data/test/amount/display_test.rb +181 -0
- data/test/amount/locale_test.rb +590 -0
- data/test/amount/parser_test.rb +29 -0
- data/test/amount/registry/generated_constructors_test.rb +94 -0
- data/test/amount/registry_test.rb +149 -0
- data/test/amount/ui_parser_test.rb +284 -0
- data/test/amount_test.rb +55 -0
- data/test/dummy/config/application.rb +3 -0
- data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
- data/test/integration/postgresql_test.rb +94 -0
- data/test/support/active_record_test_support.rb +65 -0
- data/test/support/error_helpers.rb +22 -0
- data/test/support/locale_test_setup.rb +36 -0
- data/test/test_helper.rb +1 -0
- metadata +47 -11
- data/test/postgresql_integration_test.rb +0 -71
- data/test/test_active_record.rb +0 -375
- data/test/test_amount.rb +0 -663
- data/test/test_registry_generator.rb +0 -104
data/lib/amount/active_record.rb
CHANGED
|
@@ -24,6 +24,17 @@ class Amount
|
|
|
24
24
|
# has_amount :fee, symbol: :SOL
|
|
25
25
|
# end
|
|
26
26
|
module ActiveRecord
|
|
27
|
+
# English defaults for the validator's error keys. Apps override them in
|
|
28
|
+
# their own locale files.
|
|
29
|
+
LOCALE_PATH = File.expand_path("active_record/locale/en.yml", __dir__).freeze
|
|
30
|
+
|
|
31
|
+
# Registers the gem's locale file with I18n, once.
|
|
32
|
+
#
|
|
33
|
+
# @return [void]
|
|
34
|
+
def self.install_locale!
|
|
35
|
+
::I18n.load_path << LOCALE_PATH unless ::I18n.load_path.include?(LOCALE_PATH)
|
|
36
|
+
end
|
|
37
|
+
|
|
27
38
|
# Installs the migration DSL and model macros into ActiveRecord.
|
|
28
39
|
#
|
|
29
40
|
# This is called automatically when the file is required.
|
|
@@ -41,4 +52,5 @@ class Amount
|
|
|
41
52
|
end
|
|
42
53
|
end
|
|
43
54
|
|
|
55
|
+
Amount::ActiveRecord.install_locale!
|
|
44
56
|
Amount::ActiveRecord.install!
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
class Amount
|
|
6
|
+
# Internal: compact notation for large numbers, used by `ui(compact: true)`.
|
|
7
|
+
#
|
|
8
|
+
# A number of at least one thousand is divided by the largest scale it
|
|
9
|
+
# reaches (thousand, million, ... up to decillion, 10**33), rounded to a number of
|
|
10
|
+
# significant digits in the given direction, trimmed of trailing zeros and
|
|
11
|
+
# joined with a scale word:
|
|
12
|
+
#
|
|
13
|
+
# - when localizing, the word is `number.human.decimal_units.units.<scale>`
|
|
14
|
+
# (pluralized with the compacted number) joined by
|
|
15
|
+
# `number.human.decimal_units.format` (default `"%n %u"`), the keys Rails'
|
|
16
|
+
# `number_to_human` uses. A scale without a translation falls back to the
|
|
17
|
+
# short form below.
|
|
18
|
+
# - otherwise the word is `K`, `M`, `B`, `T`, `Qa`, `Qi`, `Sx`, `Sp`, `Oc`,
|
|
19
|
+
# `No` or `Dc`, joined as `"%n%u"`.
|
|
20
|
+
#
|
|
21
|
+
# Numbers past decillions stay in decillions (`1500Dc`).
|
|
22
|
+
#
|
|
23
|
+
# Numbers below one thousand are not compacted; {Display} formats them as
|
|
24
|
+
# usual.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# Amount::Compact.format(BigDecimal("1999999"), precision: 3, direction: :floor, locale: nil)
|
|
28
|
+
# # => "1.99M"
|
|
29
|
+
module Compact
|
|
30
|
+
DEFAULT_PRECISION = 3
|
|
31
|
+
SHORT_FORMAT = "%n%u"
|
|
32
|
+
LOCALE_FORMAT = "%n %u"
|
|
33
|
+
LOCALE_FORMAT_KEY = "number.human.decimal_units.format"
|
|
34
|
+
LOCALE_UNITS_KEY = "number.human.decimal_units.units"
|
|
35
|
+
|
|
36
|
+
# [exponent, I18n key, short form], smallest first.
|
|
37
|
+
SCALES = [
|
|
38
|
+
[3, :thousand, "K"],
|
|
39
|
+
[6, :million, "M"],
|
|
40
|
+
[9, :billion, "B"],
|
|
41
|
+
[12, :trillion, "T"],
|
|
42
|
+
[15, :quadrillion, "Qa"],
|
|
43
|
+
[18, :quintillion, "Qi"],
|
|
44
|
+
[21, :sextillion, "Sx"],
|
|
45
|
+
[24, :septillion, "Sp"],
|
|
46
|
+
[27, :octillion, "Oc"],
|
|
47
|
+
[30, :nonillion, "No"],
|
|
48
|
+
[33, :decillion, "Dc"]
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Reads the `compact:` option of `ui`.
|
|
54
|
+
# @param option [Boolean, Hash, nil] `true`, `false`/`nil`, or
|
|
55
|
+
# `{ precision: Integer }`
|
|
56
|
+
# @return [Integer, nil] significant digits, or `nil` when compact
|
|
57
|
+
# notation is off
|
|
58
|
+
# @raise [ArgumentError] for anything else
|
|
59
|
+
def precision(option)
|
|
60
|
+
case option
|
|
61
|
+
when nil, false then nil
|
|
62
|
+
when true then DEFAULT_PRECISION
|
|
63
|
+
when Hash then precision_from_hash(option)
|
|
64
|
+
else raise ArgumentError, "compact: must be true, false or { precision: Integer }, got #{option.inspect}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param value [BigDecimal]
|
|
69
|
+
# @return [Boolean] whether the number is large enough to compact
|
|
70
|
+
def applies?(value)
|
|
71
|
+
value.abs >= BigDecimal(10)**SCALES.first[0]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The compacted number with its scale word, e.g. `"1.5M"` or
|
|
75
|
+
# `"1,5 Mio."`. Call only when {applies?} is true.
|
|
76
|
+
# @param value [BigDecimal]
|
|
77
|
+
# @param precision [Integer] significant digits
|
|
78
|
+
# @param direction [Symbol] `:floor` or `:ceil`, as in `ui`
|
|
79
|
+
# @param locale [Symbol, String, nil]
|
|
80
|
+
# @return [String]
|
|
81
|
+
def format(value, precision:, direction:, locale:)
|
|
82
|
+
index = SCALES.rindex { |exponent, _, _| value.abs >= BigDecimal(10)**exponent }
|
|
83
|
+
loop do
|
|
84
|
+
mantissa, decimals = round(value, SCALES[index][0], precision, direction)
|
|
85
|
+
# Rounding up can reach the next scale: 999,999 ceils to 1000K, shown as 1M.
|
|
86
|
+
if mantissa.abs >= 1000 && index < SCALES.length - 1
|
|
87
|
+
index += 1
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
return join(number(mantissa, decimals, locale), mantissa, SCALES[index], locale)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Divides by 10**exponent and rounds to `precision` significant digits.
|
|
96
|
+
# Returns the mantissa and how many decimals it may show.
|
|
97
|
+
def round(value, exponent, precision, direction)
|
|
98
|
+
mantissa = value / (BigDecimal(10)**exponent)
|
|
99
|
+
digits = [mantissa.abs.to_i.to_s.length, 1].max
|
|
100
|
+
decimals = precision - digits
|
|
101
|
+
[Display.round(mantissa, decimals, direction), [decimals, 0].max]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def number(mantissa, decimals, locale)
|
|
105
|
+
str = mantissa.to_s("F")
|
|
106
|
+
int, frac = str.split(".", 2)
|
|
107
|
+
frac = frac.to_s[0, decimals].sub(/0+\z/, "")
|
|
108
|
+
str = frac.empty? ? int : "#{int}.#{frac}"
|
|
109
|
+
locale ? Locale.apply(str, locale) : str
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def join(number, mantissa, scale, locale)
|
|
113
|
+
_, key, short = scale
|
|
114
|
+
word = locale && Locale.translate("#{LOCALE_UNITS_KEY}.#{key}", locale, "", mantissa)
|
|
115
|
+
return Formattable.fill(SHORT_FORMAT, number:, label: short) if word.nil? || word.empty?
|
|
116
|
+
|
|
117
|
+
Formattable.fill(locale_format(locale), number:, label: word)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def locale_format(locale)
|
|
121
|
+
found = Locale.translate(LOCALE_FORMAT_KEY, locale, LOCALE_FORMAT)
|
|
122
|
+
Formattable.valid_template?(found) ? found : LOCALE_FORMAT
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def precision_from_hash(option)
|
|
126
|
+
unknown = option.keys - [:precision]
|
|
127
|
+
raise ArgumentError, "unknown compact: option(s) #{unknown.map(&:inspect).join(', ')}" unless unknown.empty?
|
|
128
|
+
|
|
129
|
+
digits = option.fetch(:precision, DEFAULT_PRECISION)
|
|
130
|
+
return digits if digits.is_a?(Integer) && digits.positive?
|
|
131
|
+
|
|
132
|
+
raise ArgumentError, "compact: precision must be a positive Integer, got #{digits.inspect}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private_class_method :round, :number, :join, :locale_format, :precision_from_hash
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -5,7 +5,7 @@ class Amount
|
|
|
5
5
|
# `[parts, remainder]`, preserving the invariant that the parts plus
|
|
6
6
|
# remainder always sum back to the receiver's atomic value (including
|
|
7
7
|
# the sign).
|
|
8
|
-
module
|
|
8
|
+
module Allocatable
|
|
9
9
|
# Splits into equal parts and returns the leftover explicitly.
|
|
10
10
|
#
|
|
11
11
|
# @param n [Integer]
|
|
@@ -6,10 +6,10 @@ class Amount
|
|
|
6
6
|
#
|
|
7
7
|
# All operators preserve the receiver's class via `build`, so subclass
|
|
8
8
|
# identity (`GoldAmount`) survives through `+`, `-`, `*`, `/`, `abs`, `-@`.
|
|
9
|
-
module
|
|
9
|
+
module Calculable
|
|
10
10
|
# @return [Amount]
|
|
11
11
|
# @example
|
|
12
|
-
# Amount.
|
|
12
|
+
# Amount.of_usdc("-1").abs
|
|
13
13
|
# # => #<Amount USDC $1.00>
|
|
14
14
|
def abs
|
|
15
15
|
build(@atomic.abs)
|
|
@@ -17,7 +17,7 @@ class Amount
|
|
|
17
17
|
|
|
18
18
|
# @return [Amount]
|
|
19
19
|
# @example
|
|
20
|
-
# -Amount.
|
|
20
|
+
# -Amount.of_usdc("1")
|
|
21
21
|
# # => #<Amount USDC -$1.00>
|
|
22
22
|
def -@
|
|
23
23
|
build(-@atomic)
|
|
@@ -27,11 +27,11 @@ class Amount
|
|
|
27
27
|
# @return [Amount]
|
|
28
28
|
# @raise [TypeMismatch]
|
|
29
29
|
# @example Same-type addition
|
|
30
|
-
# Amount.
|
|
30
|
+
# Amount.of_usdc("1.50") + Amount.of_usdc("0.50")
|
|
31
31
|
#
|
|
32
32
|
# @example Cross-type addition using a registered directional rate
|
|
33
33
|
# Amount.register_default_rate :USD, :USDC, "1"
|
|
34
|
-
# Amount.
|
|
34
|
+
# Amount.of_usdc("10.00") + Amount.new("5.00", :USD)
|
|
35
35
|
def +(other)
|
|
36
36
|
rhs = coerce_other_to_self_type!(other)
|
|
37
37
|
build(@atomic + rhs.atomic)
|
|
@@ -41,7 +41,7 @@ class Amount
|
|
|
41
41
|
# @return [Amount]
|
|
42
42
|
# @raise [TypeMismatch]
|
|
43
43
|
# @example
|
|
44
|
-
# Amount.
|
|
44
|
+
# Amount.of_usdc("2.00") - Amount.of_usdc("0.50")
|
|
45
45
|
def -(other)
|
|
46
46
|
rhs = coerce_other_to_self_type!(other)
|
|
47
47
|
build(@atomic - rhs.atomic)
|
|
@@ -51,7 +51,7 @@ class Amount
|
|
|
51
51
|
# @return [Amount]
|
|
52
52
|
# @raise [TypeMismatch]
|
|
53
53
|
# @example
|
|
54
|
-
# Amount.
|
|
54
|
+
# Amount.of_usdc("1.25") * 2
|
|
55
55
|
def *(scalar)
|
|
56
56
|
ensure_scalar!(scalar)
|
|
57
57
|
build((BigDecimal(@atomic) * Amount.coerce_decimal(scalar)).to_i)
|
|
@@ -61,10 +61,10 @@ class Amount
|
|
|
61
61
|
# @return [Amount, BigDecimal]
|
|
62
62
|
# @raise [TypeMismatch, ZeroDivisionError]
|
|
63
63
|
# @example Dividing by a scalar returns an amount
|
|
64
|
-
# Amount.
|
|
64
|
+
# Amount.of_usdc("1.00") / 2
|
|
65
65
|
#
|
|
66
66
|
# @example Dividing by an amount returns a ratio
|
|
67
|
-
# Amount.
|
|
67
|
+
# Amount.of_usdc("10.00") / Amount.of_usdc("2.00")
|
|
68
68
|
def /(other)
|
|
69
69
|
if other.is_a?(Amount)
|
|
70
70
|
ensure_same_type!(other)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
class Amount
|
|
6
|
+
# Concern: bringing the other side of an operation into a usable form.
|
|
7
|
+
#
|
|
8
|
+
# - {ClassMethods#coerce_decimal} turns a scalar, rate or display-unit scale
|
|
9
|
+
# into a `BigDecimal` without losing a `Rational`.
|
|
10
|
+
# - The private instance helpers bring another amount to the receiver's
|
|
11
|
+
# type. They only cross types through a registered directional default
|
|
12
|
+
# rate; without one they raise {TypeMismatch} (or return `nil` for
|
|
13
|
+
# comparisons). There is no implicit conversion.
|
|
14
|
+
module Coercible
|
|
15
|
+
def self.included(base)
|
|
16
|
+
base.extend(ClassMethods)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Class-level methods extended onto any class that does `include Coercible`.
|
|
20
|
+
module ClassMethods
|
|
21
|
+
# Coerces a numeric input to BigDecimal in a way that preserves Rational
|
|
22
|
+
# values. `BigDecimal(value.to_s)` raises `ArgumentError` for Rational
|
|
23
|
+
# because `Rational#to_s` produces strings like `"3/2"`. This helper is
|
|
24
|
+
# the single place every call site should use to convert a scalar / rate
|
|
25
|
+
# / display-unit scale into a BigDecimal.
|
|
26
|
+
#
|
|
27
|
+
# @param value [Numeric, BigDecimal, Rational, String]
|
|
28
|
+
# @return [BigDecimal]
|
|
29
|
+
def coerce_decimal(value)
|
|
30
|
+
case value
|
|
31
|
+
when BigDecimal then value
|
|
32
|
+
when Rational then BigDecimal(value, Float::DIG + 4)
|
|
33
|
+
else BigDecimal(value.to_s)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def ensure_same_type!(other)
|
|
41
|
+
return if same_type?(other)
|
|
42
|
+
|
|
43
|
+
raise TypeMismatch, type_mismatch_message(other)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The other amount in the receiver's type: itself when the types match,
|
|
47
|
+
# converted through a registered default rate when one exists, else `nil`.
|
|
48
|
+
def coerce_other_to_self_type(other)
|
|
49
|
+
return other if same_type?(other)
|
|
50
|
+
return unless other.is_a?(Amount)
|
|
51
|
+
|
|
52
|
+
other.to(symbol)
|
|
53
|
+
rescue Registry::NoDefaultRate
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def coerce_other_to_self_type!(other)
|
|
58
|
+
coerce_other_to_self_type(other) || raise(TypeMismatch, type_mismatch_message(other))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def type_mismatch_message(other)
|
|
62
|
+
"type mismatch: #{symbol} vs #{other.is_a?(Amount) ? other.symbol : other.class}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
class Amount
|
|
6
|
+
# Concern: how a constructor input becomes an atomic `Integer`, and which
|
|
7
|
+
# class an amount is built as.
|
|
8
|
+
#
|
|
9
|
+
# Input inference rules:
|
|
10
|
+
# - `Integer` => atomic units
|
|
11
|
+
# - `String` => UI decimal value
|
|
12
|
+
# - `Float`, `BigDecimal`, `Rational` => UI decimal value (a `Float` goes
|
|
13
|
+
# through its string form, never through float math)
|
|
14
|
+
# - `from:` (`:atomic`, `:ui`, `:float`) overrides inference
|
|
15
|
+
#
|
|
16
|
+
# Class identity: a type registered with `class:` is always built as that
|
|
17
|
+
# class. `Amount.new` dispatches to it, and every operator that returns an
|
|
18
|
+
# amount goes through {#build} so the receiver's class carries through.
|
|
19
|
+
module Constructible
|
|
20
|
+
def self.included(base)
|
|
21
|
+
base.extend(ClassMethods)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Class-level methods extended onto any class that does
|
|
25
|
+
# `include Constructible`.
|
|
26
|
+
module ClassMethods
|
|
27
|
+
# When called as `Amount.new` for a symbol whose registry entry binds a
|
|
28
|
+
# custom class, dispatch the construction to that class instead of
|
|
29
|
+
# raising. Direct calls to a subclass (`GoldAmount.new(...)`) still go
|
|
30
|
+
# through the default `Class.new` path. Calls that target the wrong
|
|
31
|
+
# subclass continue to raise from `#initialize`.
|
|
32
|
+
#
|
|
33
|
+
# @param value [Integer, String, Float, BigDecimal, Rational]
|
|
34
|
+
# @param symbol [Symbol, String]
|
|
35
|
+
# @param from [Symbol, nil]
|
|
36
|
+
# @return [Amount]
|
|
37
|
+
def new(value, symbol, from: nil)
|
|
38
|
+
if equal?(::Amount)
|
|
39
|
+
entry_class = registry.lookup(symbol.to_sym).amount_class
|
|
40
|
+
return entry_class.new(value, symbol, from:) if entry_class && entry_class != ::Amount
|
|
41
|
+
end
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Builds a same-symbol amount in the receiver's class. Used by every
|
|
49
|
+
# operator that returns an Amount so subclass identity propagates.
|
|
50
|
+
def build(atomic_value)
|
|
51
|
+
self.class.new(atomic_value, symbol, from: :atomic)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A type registered with `class: GoldAmount` must be built as
|
|
55
|
+
# `GoldAmount` (or dispatched there by `Amount.new`), never as another
|
|
56
|
+
# subclass.
|
|
57
|
+
def ensure_registered_class!
|
|
58
|
+
expected = registry_entry.amount_class
|
|
59
|
+
return unless expected && expected != Amount && self.class != Amount && !instance_of?(expected)
|
|
60
|
+
|
|
61
|
+
raise InvalidInput, "use #{expected}.new for #{symbol}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def infer_value(from, value)
|
|
65
|
+
case from || infer_type(value)
|
|
66
|
+
when :atomic then value.to_i
|
|
67
|
+
when :ui then ui_to_atomic(value)
|
|
68
|
+
when :float then ui_to_atomic(value.is_a?(Rational) ? value : value.to_s)
|
|
69
|
+
else
|
|
70
|
+
raise InvalidInput, "unknown amount format: #{value.inspect}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def infer_type(value)
|
|
75
|
+
case value
|
|
76
|
+
when Integer then :atomic
|
|
77
|
+
when String then :ui
|
|
78
|
+
when Float, BigDecimal, Rational then :float
|
|
79
|
+
else
|
|
80
|
+
raise InvalidInput, "cannot infer type for #{value.class}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ui_to_atomic(value)
|
|
85
|
+
return (value * (10**decimals)).to_i if value.is_a?(Rational)
|
|
86
|
+
|
|
87
|
+
(BigDecimal(value.to_s) * (BigDecimal(10)**decimals)).to_i
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
raise InvalidInput, "cannot parse #{value.inspect} as #{symbol}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -6,17 +6,17 @@ class Amount
|
|
|
6
6
|
# default rate. The result class is taken from the target symbol's registry
|
|
7
7
|
# entry, so a `class:`-registered subclass becomes the natural identity for
|
|
8
8
|
# conversion outputs.
|
|
9
|
-
module
|
|
9
|
+
module Convertible
|
|
10
10
|
# @param target_symbol [Symbol, String]
|
|
11
11
|
# @param rate [String, Numeric, BigDecimal, nil]
|
|
12
12
|
# @return [Amount]
|
|
13
13
|
# @raise [Amount::Registry::NoDefaultRate] if no explicit or registered rate is available
|
|
14
14
|
# @example Using an explicit one-off rate
|
|
15
|
-
# Amount.
|
|
15
|
+
# Amount.of_usdc("100").to(:GOLD, rate: "0.00042")
|
|
16
16
|
#
|
|
17
17
|
# @example Using a registered default rate
|
|
18
18
|
# Amount.register_default_rate :USDC, :USD, "1"
|
|
19
|
-
# Amount.
|
|
19
|
+
# Amount.of_usdc("1.50").to(:USD)
|
|
20
20
|
def to(target_symbol, rate: nil)
|
|
21
21
|
target_symbol = target_symbol.to_sym
|
|
22
22
|
return self.class.new(@atomic, symbol, from: :atomic) if target_symbol == symbol
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Concern: where the number and its unit label go in decorated output.
|
|
5
|
+
#
|
|
6
|
+
# A format is a template in the style of Rails' `number.currency.format.format`:
|
|
7
|
+
# `%n` is the formatted number (sign included) and `%u` the unit label.
|
|
8
|
+
# Everything else is copied as given, so a non-breaking space in
|
|
9
|
+
# `"%n %u"` stays non-breaking.
|
|
10
|
+
#
|
|
11
|
+
# Included in {Amount}, it answers one question per amount, {#display_format}:
|
|
12
|
+
# which template does decorated `ui` output use, for this type or one of its
|
|
13
|
+
# display units, in this locale? The module functions are the template
|
|
14
|
+
# primitives the registry (validation) and {Display} (filling) share.
|
|
15
|
+
#
|
|
16
|
+
# A template is resolved, first match wins:
|
|
17
|
+
#
|
|
18
|
+
# - type: `amounts.formats.<symbol downcased>` (when localizing), then the
|
|
19
|
+
# registered `display_format`, which the registry derives from
|
|
20
|
+
# `display_position` when no template was given.
|
|
21
|
+
# - display unit: `amounts.display_unit_formats.<symbol downcased>.<unit>`
|
|
22
|
+
# (when localizing), then the unit's `:format`, then its `:position`, then
|
|
23
|
+
# the type's template as above.
|
|
24
|
+
#
|
|
25
|
+
# A locale template without `%n` is ignored, so a typo in a locale file can't
|
|
26
|
+
# drop the amount from the output.
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# Amount.register :EUR, decimals: 2, display_symbol: "€", display_format: "%u%n"
|
|
30
|
+
# # de.yml: amounts.formats.eur: "%n %u"
|
|
31
|
+
#
|
|
32
|
+
# Amount.of_eur("1").display_format # => "%u%n"
|
|
33
|
+
# Amount.of_eur("1").display_format(locale: :de) # => "%n %u"
|
|
34
|
+
# Amount::Formattable.fill("%n %u", number: "12,50", label: "€")
|
|
35
|
+
# # => "12,50 €"
|
|
36
|
+
module Formattable
|
|
37
|
+
NUMBER = "%n"
|
|
38
|
+
LABEL = "%u"
|
|
39
|
+
PREFIX = "%u%n"
|
|
40
|
+
SUFFIX = "%n %u"
|
|
41
|
+
|
|
42
|
+
PLACEHOLDER = /%[nu]/
|
|
43
|
+
private_constant :PLACEHOLDER
|
|
44
|
+
|
|
45
|
+
# The template a `display_position` stands for: `:prefix` is `"%u%n"`,
|
|
46
|
+
# anything else `"%n %u"`, matching the output before templates existed.
|
|
47
|
+
# @param position [Symbol, nil]
|
|
48
|
+
# @return [String]
|
|
49
|
+
def self.template_for(position)
|
|
50
|
+
position == :prefix ? PREFIX : SUFFIX
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A usable template is a String that places the number.
|
|
54
|
+
# @param template [Object]
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def self.valid_template?(template)
|
|
57
|
+
template.is_a?(String) && template.include?(NUMBER)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Fills a template in one pass, so a `%` inside the number or label is
|
|
61
|
+
# never read as a placeholder.
|
|
62
|
+
# @param template [String]
|
|
63
|
+
# @param number [String] the formatted number
|
|
64
|
+
# @param label [String] the unit label
|
|
65
|
+
# @return [String]
|
|
66
|
+
def self.fill(template, number:, label:)
|
|
67
|
+
template.gsub(PLACEHOLDER, NUMBER => number, LABEL => label)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The template decorated `ui` output uses for this amount.
|
|
71
|
+
#
|
|
72
|
+
# @param unit [Symbol, nil] a display unit, or `nil` for the type itself
|
|
73
|
+
# @param locale [Symbol, String, nil] look for a locale template too
|
|
74
|
+
# (requires I18n). When `nil`, uses `I18n.locale` if `Amount.localize`
|
|
75
|
+
# is true, else only the registered templates.
|
|
76
|
+
# @return [String]
|
|
77
|
+
# @raise [Registry::InvalidDisplayUnit] for an unknown display unit
|
|
78
|
+
# @example
|
|
79
|
+
# Amount.of_usd("1").display_format # => "%u%n"
|
|
80
|
+
# Amount.of_gold("1").display_format(unit: :gram) # => "%n %u"
|
|
81
|
+
def display_format(unit: nil, locale: nil)
|
|
82
|
+
locale = Locale.effective(locale)
|
|
83
|
+
return type_display_format(locale) unless unit
|
|
84
|
+
|
|
85
|
+
spec = registry_entry.display_unit(unit)
|
|
86
|
+
(locale && locale_display_format(unit_format_key(unit), locale)) ||
|
|
87
|
+
spec[:format] ||
|
|
88
|
+
(spec[:position] && Formattable.template_for(spec[:position])) ||
|
|
89
|
+
type_display_format(locale)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def type_display_format(locale)
|
|
95
|
+
(locale && locale_display_format(type_format_key, locale)) ||
|
|
96
|
+
registry_entry.display_format
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def locale_display_format(key, locale)
|
|
100
|
+
found = Locale.translate(key, locale, nil)
|
|
101
|
+
found if Formattable.valid_template?(found)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def type_format_key
|
|
105
|
+
"amounts.formats.#{symbol.to_s.downcase}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def unit_format_key(unit)
|
|
109
|
+
"amounts.display_unit_formats.#{symbol.to_s.downcase}.#{unit}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Concern: the global switch that makes output and input follow
|
|
5
|
+
# `I18n.locale` when no `locale:` is given.
|
|
6
|
+
#
|
|
7
|
+
# Every locale-aware method (`ui`, `unit_label`, `display_format`,
|
|
8
|
+
# `Amount.parse_ui`) resolves its locale through {Locale.effective}, which
|
|
9
|
+
# reads this switch.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# Amount.localize = true
|
|
13
|
+
# I18n.locale = :de
|
|
14
|
+
# Amount.of_usd("1500.5").ui # formatted with de's number.format
|
|
15
|
+
module Localizable
|
|
16
|
+
def self.included(base)
|
|
17
|
+
base.extend(ClassMethods)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Class-level methods extended onto any class that does
|
|
21
|
+
# `include Localizable`.
|
|
22
|
+
module ClassMethods
|
|
23
|
+
# Whether `ui` formats numbers for `I18n.locale` when no `locale:` is
|
|
24
|
+
# given. Defaults to `false`.
|
|
25
|
+
# @return [Boolean]
|
|
26
|
+
def localize?
|
|
27
|
+
@localize ? true : false
|
|
28
|
+
end
|
|
29
|
+
alias localize localize?
|
|
30
|
+
|
|
31
|
+
# @param value [Boolean]
|
|
32
|
+
# @raise [ArgumentError] when enabling without I18n loaded
|
|
33
|
+
def localize=(value)
|
|
34
|
+
Locale.require_i18n! if value
|
|
35
|
+
@localize = value ? true : false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
class Amount
|
|
4
4
|
# Comparison, equality, hashing, and sign predicates for `Amount`.
|
|
5
5
|
#
|
|
6
|
-
# Pulls in `Comparable` itself so consumers only need `include
|
|
6
|
+
# Pulls in `Comparable` itself so consumers only need `include Orderable`
|
|
7
7
|
# to get `<`, `<=`, `>`, `>=`, `between?`, `clamp`, and `Enumerable#min`/
|
|
8
8
|
# `#max` alongside the explicit `<=>` / `==` / `eql?` / `hash` defined here.
|
|
9
|
-
module
|
|
9
|
+
module Orderable
|
|
10
10
|
include Comparable
|
|
11
11
|
# @param other [Object]
|
|
12
12
|
# @return [Boolean]
|
|
13
13
|
# @example
|
|
14
|
-
# Amount.
|
|
14
|
+
# Amount.of_usdc("1").same_type?(Amount.of_usdc("2"))
|
|
15
15
|
# # => true
|
|
16
16
|
def same_type?(other)
|
|
17
17
|
other.is_a?(Amount) && other.symbol == symbol
|
|
@@ -19,26 +19,26 @@ class Amount
|
|
|
19
19
|
|
|
20
20
|
# @return [Boolean]
|
|
21
21
|
# @example
|
|
22
|
-
# Amount.
|
|
22
|
+
# Amount.of_usdc(0, from: :atomic).zero?
|
|
23
23
|
# # => true
|
|
24
24
|
def zero? = @atomic.zero?
|
|
25
25
|
|
|
26
26
|
# @return [Boolean]
|
|
27
27
|
# @example
|
|
28
|
-
# Amount.
|
|
28
|
+
# Amount.of_usdc("1").positive?
|
|
29
29
|
# # => true
|
|
30
30
|
def positive? = @atomic.positive?
|
|
31
31
|
|
|
32
32
|
# @return [Boolean]
|
|
33
33
|
# @example
|
|
34
|
-
# Amount.
|
|
34
|
+
# Amount.of_usdc("-1").negative?
|
|
35
35
|
# # => true
|
|
36
36
|
def negative? = @atomic.negative?
|
|
37
37
|
|
|
38
38
|
# @param other [Object]
|
|
39
39
|
# @return [-1, 0, 1, nil]
|
|
40
40
|
# @example
|
|
41
|
-
# Amount.
|
|
41
|
+
# Amount.of_usdc("1") <=> Amount.of_usdc("2")
|
|
42
42
|
# # => -1
|
|
43
43
|
def <=>(other)
|
|
44
44
|
return nil unless other.is_a?(Amount)
|
|
@@ -52,7 +52,7 @@ class Amount
|
|
|
52
52
|
# @param other [Object]
|
|
53
53
|
# @return [Boolean]
|
|
54
54
|
# @example
|
|
55
|
-
# Amount.
|
|
55
|
+
# Amount.of_usdc("1.50") == Amount.of_usdc("1.50")
|
|
56
56
|
# # => true
|
|
57
57
|
def ==(other)
|
|
58
58
|
same_type?(other) && @atomic == other.atomic
|
|
@@ -61,7 +61,7 @@ class Amount
|
|
|
61
61
|
# @param other [Object]
|
|
62
62
|
# @return [Boolean]
|
|
63
63
|
# @example Hash-key equality keeps class and symbol identity
|
|
64
|
-
# Amount.
|
|
64
|
+
# Amount.of_usdc("1").eql?(Amount.of_usdc("1"))
|
|
65
65
|
# # => true
|
|
66
66
|
def eql?(other)
|
|
67
67
|
other.class == self.class && symbol == other.symbol && @atomic == other.atomic
|
|
@@ -69,7 +69,7 @@ class Amount
|
|
|
69
69
|
|
|
70
70
|
# @return [Integer]
|
|
71
71
|
# @example
|
|
72
|
-
# { Amount.
|
|
72
|
+
# { Amount.of_usdc("1") => :ok }[Amount.of_usdc("1")]
|
|
73
73
|
# # => :ok
|
|
74
74
|
def hash
|
|
75
75
|
[self.class, symbol, @atomic].hash
|