amounts 0.0.8 → 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 +113 -0
- data/Gemfile +4 -0
- data/README.md +215 -1
- 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.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} +1 -1
- data/lib/amount/concerns/coercible.rb +65 -0
- data/lib/amount/concerns/constructible.rb +92 -0
- data/lib/amount/{conversion.rb → concerns/convertible.rb} +1 -1
- data/lib/amount/concerns/formattable.rb +112 -0
- data/lib/amount/concerns/localizable.rb +39 -0
- data/lib/amount/{comparison.rb → concerns/orderable.rb} +2 -2
- data/lib/amount/concerns/parsable.rb +58 -0
- data/lib/amount/concerns/registrable.rb +80 -0
- data/lib/amount/{serialization.rb → concerns/serializable.rb} +3 -3
- data/lib/amount/display.rb +81 -46
- data/lib/amount/locale.rb +102 -0
- data/lib/amount/registry.rb +69 -7
- data/lib/amount/ui_parser.rb +109 -0
- data/lib/amount/version.rb +1 -1
- data/lib/amount.rb +55 -199
- 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 -675
- data/test/test_registry_generator.rb +0 -104
|
@@ -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,7 +6,7 @@ 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
12
|
# Amount.of_usdc("-1").abs
|
|
@@ -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,7 +6,7 @@ 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]
|
|
@@ -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,10 +3,10 @@
|
|
|
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]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Concern: the class-level entry points that turn strings into amounts.
|
|
5
|
+
#
|
|
6
|
+
# Two inputs, two parsers:
|
|
7
|
+
#
|
|
8
|
+
# - {Amount.parse} reads the compact machine format (`USDC|1.50`) that
|
|
9
|
+
# `to_s` writes. See {Parser}.
|
|
10
|
+
# - {Amount.parse_ui} reads what a person typed, in a locale's number
|
|
11
|
+
# format. See {UiParser}.
|
|
12
|
+
module Parsable
|
|
13
|
+
def self.included(base)
|
|
14
|
+
base.extend(ClassMethods)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Class-level methods extended onto any class that does `include Parsable`.
|
|
18
|
+
module ClassMethods
|
|
19
|
+
# Parses the compact client-facing string representation.
|
|
20
|
+
#
|
|
21
|
+
# Accepts either the default form `SYMBOL|amount` or the explicit
|
|
22
|
+
# versioned form `v1:SYMBOL|amount`.
|
|
23
|
+
#
|
|
24
|
+
# @param str [String]
|
|
25
|
+
# @return [Amount]
|
|
26
|
+
# @raise [InvalidInput]
|
|
27
|
+
# @example Parsing the default compact format
|
|
28
|
+
# Amount.parse("USDC|1.50")
|
|
29
|
+
#
|
|
30
|
+
# @example Parsing the explicit versioned compact format
|
|
31
|
+
# Amount.parse("v1:USDC|1.50")
|
|
32
|
+
def parse(str)
|
|
33
|
+
Parser.new(str).parse
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Parses text typed by a person, in a locale's number format.
|
|
37
|
+
#
|
|
38
|
+
# The effective locale is `locale:`, then `I18n.locale` when
|
|
39
|
+
# `Amount.localize` is true, else plain parsing (`.` decimal, no
|
|
40
|
+
# delimiters). Accepts surrounding whitespace, a leading `-` or `+`,
|
|
41
|
+
# delimiters in groups of three, and the type's label (any plural form)
|
|
42
|
+
# at either end. Never rounds.
|
|
43
|
+
#
|
|
44
|
+
# @param input [String]
|
|
45
|
+
# @param symbol [Symbol, String]
|
|
46
|
+
# @param locale [Symbol, String, nil]
|
|
47
|
+
# @return [Amount] an instance of the type's registered class
|
|
48
|
+
# @raise [InvalidInput] for too many decimals, misplaced delimiters, a
|
|
49
|
+
# repeated separator, another type's label, or empty input
|
|
50
|
+
# @raise [Registry::UnknownType] for an unregistered symbol
|
|
51
|
+
# @example
|
|
52
|
+
# Amount.parse_ui("1.500,50", :USD, locale: :de) # => USD|1500.50
|
|
53
|
+
def parse_ui(input, symbol, locale: nil)
|
|
54
|
+
UiParser.new(input, symbol, locale).parse
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Concern: the shared type registry and the class-level shortcuts for
|
|
5
|
+
# registering types and default rates.
|
|
6
|
+
#
|
|
7
|
+
# The registry lives on `Amount` itself, never on a subclass, so
|
|
8
|
+
# `GoldAmount.registry` and `Amount.registry` are the same object. Swapping
|
|
9
|
+
# it removes the outgoing registry's generated constructors (`of_usdc`) and
|
|
10
|
+
# installs the incoming one's.
|
|
11
|
+
module Registrable
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend(ClassMethods)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Class-level methods extended onto any class that does
|
|
17
|
+
# `include Registrable`.
|
|
18
|
+
module ClassMethods
|
|
19
|
+
# @return [Amount::Registry]
|
|
20
|
+
# @example Accessing the shared registry
|
|
21
|
+
# Amount.registry.locked?
|
|
22
|
+
# # => false
|
|
23
|
+
def registry
|
|
24
|
+
Amount.instance_variable_get(:@registry) ||
|
|
25
|
+
replace_registry(Registry.new)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param symbol [Symbol, String]
|
|
29
|
+
# @param opts [Hash]
|
|
30
|
+
# @return [void]
|
|
31
|
+
# @example Registering a type
|
|
32
|
+
# Amount.register :USDC,
|
|
33
|
+
# decimals: 6,
|
|
34
|
+
# display_symbol: "$",
|
|
35
|
+
# display_position: :prefix,
|
|
36
|
+
# ui_decimals: 2
|
|
37
|
+
def register(symbol, **opts)
|
|
38
|
+
registry.register(symbol, **opts)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param from [Symbol, String]
|
|
42
|
+
# @param to [Symbol, String]
|
|
43
|
+
# @param rate [String, Numeric, BigDecimal]
|
|
44
|
+
# @return [void]
|
|
45
|
+
# @example Registering a directional default rate
|
|
46
|
+
# Amount.register_default_rate :USD, :USDC, "1"
|
|
47
|
+
def register_default_rate(from, to, rate)
|
|
48
|
+
registry.register_default_rate(from, to, rate)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Temporarily swaps the global registry. Intended for tests.
|
|
52
|
+
#
|
|
53
|
+
# @param registry [Amount::Registry]
|
|
54
|
+
# @yield
|
|
55
|
+
# @return [Object]
|
|
56
|
+
# @example Using a temporary registry
|
|
57
|
+
# test_registry = Amount::Registry.new
|
|
58
|
+
# Amount.with_registry(test_registry) do
|
|
59
|
+
# Amount.register :TEST, decimals: 2
|
|
60
|
+
# end
|
|
61
|
+
def with_registry(registry)
|
|
62
|
+
original = Amount.instance_variable_get(:@registry)
|
|
63
|
+
replace_registry(registry)
|
|
64
|
+
yield
|
|
65
|
+
ensure
|
|
66
|
+
replace_registry(original)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def replace_registry(registry)
|
|
72
|
+
current = Amount.instance_variable_get(:@registry)
|
|
73
|
+
current&.remove_generated_methods!
|
|
74
|
+
Amount.instance_variable_set(:@registry, registry)
|
|
75
|
+
registry&.activate_generated_methods!
|
|
76
|
+
registry
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Amount
|
|
4
|
-
# Versioned hash serialization. `include
|
|
4
|
+
# Versioned hash serialization. `include Serializable` does both halves:
|
|
5
5
|
# the instance side (`#to_h`) is mixed in directly, and the class-level
|
|
6
6
|
# entry point (`Amount.load`) is auto-extended onto the including class
|
|
7
7
|
# via the `included` hook below. The compact-string format is the
|
|
8
8
|
# responsibility of {Amount::Parser}.
|
|
9
|
-
module
|
|
9
|
+
module Serializable
|
|
10
10
|
VERSION = 1
|
|
11
11
|
|
|
12
12
|
def self.included(base)
|
|
@@ -14,7 +14,7 @@ class Amount
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# Class-level methods automatically extended onto any class that does
|
|
17
|
-
# `include
|
|
17
|
+
# `include Serializable`.
|
|
18
18
|
module ClassMethods
|
|
19
19
|
# @param payload [Hash]
|
|
20
20
|
# @return [Amount]
|