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
|
@@ -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]
|
|
@@ -41,7 +41,7 @@ class Amount
|
|
|
41
41
|
|
|
42
42
|
# @return [Hash]
|
|
43
43
|
# @example
|
|
44
|
-
# Amount.
|
|
44
|
+
# Amount.of_usdc("1.50").to_h
|
|
45
45
|
# # => { v: 1, atomic: "1500000", symbol: "USDC" }
|
|
46
46
|
def to_h
|
|
47
47
|
{
|
data/lib/amount/display.rb
CHANGED
|
@@ -5,6 +5,19 @@ require "bigdecimal"
|
|
|
5
5
|
class Amount
|
|
6
6
|
# Formats amounts for UI output without changing their type.
|
|
7
7
|
class Display
|
|
8
|
+
# Rounds to `decimals` places (negative rounds to tens, hundreds, ...),
|
|
9
|
+
# down with `:floor` and up with `:ceil`, like `ui`'s `direction:`.
|
|
10
|
+
# @param value [BigDecimal]
|
|
11
|
+
# @param decimals [Integer]
|
|
12
|
+
# @param direction [Symbol]
|
|
13
|
+
# @return [BigDecimal]
|
|
14
|
+
def self.round(value, decimals, direction)
|
|
15
|
+
factor = BigDecimal(10)**decimals
|
|
16
|
+
scaled = value * factor
|
|
17
|
+
truncated = direction == :ceil ? scaled.ceil : scaled.floor
|
|
18
|
+
truncated / factor
|
|
19
|
+
end
|
|
20
|
+
|
|
8
21
|
# @param amount [Amount]
|
|
9
22
|
def initialize(amount)
|
|
10
23
|
@amount = amount
|
|
@@ -25,19 +38,61 @@ class Amount
|
|
|
25
38
|
# number. When `nil` (the default), falls back to the display unit's
|
|
26
39
|
# setting, then the registry entry's setting. An explicit `true` or
|
|
27
40
|
# `false` overrides both.
|
|
41
|
+
# @param locale [Symbol, String, nil] format the number with this locale's
|
|
42
|
+
# `number.format` delimiter and separator (requires I18n). When `nil`,
|
|
43
|
+
# uses `I18n.locale` if `Amount.localize` is true, else plain output.
|
|
44
|
+
# @param compact [Boolean, Hash] show numbers of one thousand or more in
|
|
45
|
+
# compact notation (`1.5M`, or `1,5 Mio.` with a locale's
|
|
46
|
+
# `number.human.decimal_units` keys). `true` keeps 3 significant digits;
|
|
47
|
+
# `{ precision: 2 }` changes that. Rounds in `direction` and trims
|
|
48
|
+
# trailing zeros. The label and template wrap the compacted number; plural
|
|
49
|
+
# labels agree with the full value. See {Compact}.
|
|
28
50
|
# @return [String]
|
|
29
51
|
# @example
|
|
30
|
-
# Amount.
|
|
31
|
-
# Amount.
|
|
32
|
-
# Amount.
|
|
33
|
-
# Amount.
|
|
34
|
-
# Amount.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
# Amount.of_usdc("1.50").ui # => "$1.50"
|
|
53
|
+
# Amount.of_usdc("1.50").ui(decorated: false) # => "1.50"
|
|
54
|
+
# Amount.of_gold("1").ui(unit: :gram) # => "31.10 g"
|
|
55
|
+
# Amount.of_gold("1").ui(unit: :gram, decorated: false) # => "31.10"
|
|
56
|
+
# Amount.of_sol("2.5").ui(trim_zeros: true) # => "2.5 SOL"
|
|
57
|
+
# # EUR registered with display_format: "%u%n"; de.yml has
|
|
58
|
+
# # amounts.formats.eur: "%n\u00A0%u"
|
|
59
|
+
# Amount.of_eur("12.5").ui(locale: :en) # => "€12.50"
|
|
60
|
+
# Amount.of_eur("12.5").ui(locale: :de) # => "12,50\u00A0€"
|
|
61
|
+
# Amount.of_usd("1500000").ui(compact: true) # => "$1.5M"
|
|
62
|
+
# Amount.of_tokens(50_000_000).ui(compact: true, decorated: false) # => "50M"
|
|
63
|
+
def ui(unit: nil, direction: :floor, decorated: true, trim_zeros: nil, locale: nil, compact: false)
|
|
64
|
+
locale = Locale.effective(locale)
|
|
65
|
+
precision = Compact.precision(compact)
|
|
66
|
+
spec = @entry.display_unit(unit) if unit
|
|
67
|
+
value = spec ? @amount.decimal * Amount.coerce_decimal(spec[:scale]) : @amount.decimal
|
|
68
|
+
decimals = spec&.dig(:ui_decimals) || @entry.ui_decimals
|
|
69
|
+
rounded = round(value, decimals, direction)
|
|
70
|
+
formatted =
|
|
71
|
+
if precision && Compact.applies?(value)
|
|
72
|
+
Compact.format(value, precision:, direction:, locale:)
|
|
73
|
+
else
|
|
74
|
+
format_number(rounded, decimals, resolve_trim(trim_zeros, spec), locale)
|
|
75
|
+
end
|
|
76
|
+
return formatted unless decorated
|
|
77
|
+
|
|
78
|
+
decorate(formatted, rounded, unit, spec, locale)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The unit label alone, for column headers and chips that render it
|
|
82
|
+
# separately. Follows the same rules as `ui`: translated through I18n only
|
|
83
|
+
# when a locale is given or `Amount.localize` is true, else `display_symbol`
|
|
84
|
+
# (or the display unit's `:symbol`).
|
|
85
|
+
# @param unit [Symbol, nil] display unit
|
|
86
|
+
# @param locale [Symbol, String, nil]
|
|
87
|
+
# @param count [Numeric, String, nil] the number the label agrees with, for
|
|
88
|
+
# plural labels. Without it the `:other` form is used.
|
|
89
|
+
# @return [String]
|
|
90
|
+
# @example
|
|
91
|
+
# Amount.of_searches(3).unit_label(locale: :de) # => "Suchen"
|
|
92
|
+
# Amount.of_searches(1).unit_label(count: 1) # => "search" (hash display_symbol)
|
|
93
|
+
def unit_label(unit: nil, locale: nil, count: nil)
|
|
94
|
+
count = Amount.coerce_decimal(count) unless count.nil?
|
|
95
|
+
label(unit, Locale.effective(locale), nil, count)
|
|
41
96
|
end
|
|
42
97
|
|
|
43
98
|
# @return [String]
|
|
@@ -49,56 +104,42 @@ class Amount
|
|
|
49
104
|
# @param unit [Symbol]
|
|
50
105
|
# @return [BigDecimal]
|
|
51
106
|
def in_unit(unit)
|
|
52
|
-
unit_spec =
|
|
107
|
+
unit_spec = @entry.display_unit(unit)
|
|
53
108
|
@amount.decimal * Amount.coerce_decimal(unit_spec[:scale])
|
|
54
109
|
end
|
|
55
110
|
|
|
56
111
|
private
|
|
57
112
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def render_display_unit(unit, direction, decorated:, trim_zeros:)
|
|
67
|
-
spec = fetch_display_unit(unit)
|
|
68
|
-
scaled = @amount.decimal * Amount.coerce_decimal(spec[:scale])
|
|
69
|
-
decimals = spec[:ui_decimals] || @entry.ui_decimals
|
|
70
|
-
rounded = round(scaled, decimals, direction)
|
|
71
|
-
formatted = format_number(rounded, decimals, resolve_trim(trim_zeros, spec))
|
|
72
|
-
return formatted unless decorated
|
|
113
|
+
# Places the number and its label with the template from
|
|
114
|
+
# {Formattable#display_format}. The label agrees with `count`, the number
|
|
115
|
+
# as displayed. Without a label there is nothing to place, so the number is
|
|
116
|
+
# returned alone.
|
|
117
|
+
def decorate(number, count, unit, spec, locale)
|
|
118
|
+
label = label(unit, locale, spec, count)
|
|
119
|
+
return number if label.nil? || label.empty?
|
|
73
120
|
|
|
74
|
-
|
|
75
|
-
formatted,
|
|
76
|
-
spec[:symbol] || @entry.display_symbol,
|
|
77
|
-
spec[:position] || @entry.display_position
|
|
78
|
-
)
|
|
121
|
+
Formattable.fill(@amount.display_format(unit:, locale:), number:, label:)
|
|
79
122
|
end
|
|
80
123
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
124
|
+
def label(unit, locale, spec = nil, count = nil)
|
|
125
|
+
spec ||= @entry.display_unit(unit) if unit
|
|
126
|
+
if spec
|
|
127
|
+
return Locale.plain(spec[:symbol] || @entry.display_symbol, count) unless locale
|
|
86
128
|
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
Locale.unit_label(@entry, unit.to_sym, spec, locale, count)
|
|
130
|
+
else
|
|
131
|
+
locale ? Locale.type_label(@entry, locale, count) : Locale.plain(@entry.display_symbol, count)
|
|
89
132
|
end
|
|
90
133
|
end
|
|
91
134
|
|
|
92
135
|
def round(value, decimals, direction)
|
|
93
|
-
|
|
94
|
-
scaled = value * factor
|
|
95
|
-
truncated = direction == :ceil ? scaled.ceil : scaled.floor
|
|
96
|
-
truncated / factor
|
|
136
|
+
self.class.round(value, decimals, direction)
|
|
97
137
|
end
|
|
98
138
|
|
|
99
|
-
def format_number(value, decimals, trim)
|
|
139
|
+
def format_number(value, decimals, trim, locale = nil)
|
|
100
140
|
str = format("%.#{decimals}f", value)
|
|
101
|
-
|
|
141
|
+
str = str.sub(/(\.\d*?)0+\z/, '\1').chomp('.') if trim
|
|
142
|
+
locale ? Locale.apply(str, locale) : str
|
|
102
143
|
end
|
|
103
144
|
|
|
104
145
|
def resolve_trim(call_site, spec = nil)
|
|
@@ -107,11 +148,5 @@ class Amount
|
|
|
107
148
|
|
|
108
149
|
@entry.trim_zeros
|
|
109
150
|
end
|
|
110
|
-
|
|
111
|
-
def apply_symbol(str, symbol, position)
|
|
112
|
-
return str if symbol.nil? || symbol.empty?
|
|
113
|
-
|
|
114
|
-
position == :prefix ? "#{symbol}#{str}" : "#{str} #{symbol}"
|
|
115
|
-
end
|
|
116
151
|
end
|
|
117
152
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Internal: decides the effective locale and reads number formatting from
|
|
5
|
+
# I18n. I18n is used only when it is already loaded; it is never required.
|
|
6
|
+
module Locale
|
|
7
|
+
DEFAULT_DELIMITER = ","
|
|
8
|
+
DEFAULT_SEPARATOR = "."
|
|
9
|
+
MISSING_I18N = "the i18n gem is required for locale-aware output; add it to your Gemfile and require it"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @return [Boolean]
|
|
14
|
+
def i18n_loaded?
|
|
15
|
+
defined?(::I18n) ? true : false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @raise [ArgumentError] when I18n isn't loaded
|
|
19
|
+
def require_i18n!
|
|
20
|
+
raise ArgumentError, MISSING_I18N unless i18n_loaded?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 1. an explicit locale, 2. I18n.locale when Amount.localize, 3. nil.
|
|
24
|
+
# @param explicit [Symbol, String, nil]
|
|
25
|
+
# @return [Symbol, String, nil]
|
|
26
|
+
def effective(explicit = nil)
|
|
27
|
+
unless explicit.nil?
|
|
28
|
+
require_i18n!
|
|
29
|
+
return explicit
|
|
30
|
+
end
|
|
31
|
+
return nil unless Amount.localize?
|
|
32
|
+
|
|
33
|
+
require_i18n!
|
|
34
|
+
::I18n.locale
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Hash{Symbol=>String}] `:delimiter` and `:separator`
|
|
38
|
+
def number_format(locale)
|
|
39
|
+
format = ::I18n.t("number.format", locale:, default: {})
|
|
40
|
+
format = {} unless format.is_a?(Hash)
|
|
41
|
+
{
|
|
42
|
+
delimiter: format[:delimiter] || DEFAULT_DELIMITER,
|
|
43
|
+
separator: format[:separator] || DEFAULT_SEPARATOR
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Picks a form from a plural hash without I18n: `:one` when the number
|
|
48
|
+
# equals 1, else `:other`. Strings pass through.
|
|
49
|
+
# @param label [String, Hash, nil]
|
|
50
|
+
# @param count [BigDecimal, nil]
|
|
51
|
+
def plain(label, count)
|
|
52
|
+
return label unless label.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
count == 1 && label.key?(:one) ? label[:one] : label[:other]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Label of a registered type: its `label_key`, then
|
|
58
|
+
# `amounts.units.<symbol downcased>`, then `display_symbol`.
|
|
59
|
+
# @return [String, nil]
|
|
60
|
+
def type_label(entry, locale, count = nil)
|
|
61
|
+
key = entry.label_key || "amounts.units.#{entry.symbol.to_s.downcase}"
|
|
62
|
+
translate(key, locale, entry.display_symbol, count)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Label of a display unit: the spec's `label_key`, then
|
|
66
|
+
# `amounts.display_units.<symbol downcased>.<unit>`, then the spec's
|
|
67
|
+
# `:symbol`, then the type's label.
|
|
68
|
+
# @return [String, nil]
|
|
69
|
+
def unit_label(entry, unit, spec, locale, count = nil)
|
|
70
|
+
key = spec[:label_key] || "amounts.display_units.#{entry.symbol.to_s.downcase}.#{unit}"
|
|
71
|
+
translate(key, locale, spec[:symbol] || type_label(entry, locale, count), count)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Looks a key up, falling back to `default` so a miss never yields
|
|
75
|
+
# "translation missing". With a count, I18n pluralizes a hash translation
|
|
76
|
+
# or default using the locale's rules; without one, or when the form is
|
|
77
|
+
# missing, the `:other` form is used.
|
|
78
|
+
def translate(key, locale, default, count = nil)
|
|
79
|
+
opts = count.nil? ? {} : { count: whole(count) }
|
|
80
|
+
found = ::I18n.t(key.to_s, locale:, default:, **opts)
|
|
81
|
+
found.is_a?(Hash) ? found[:other] : found
|
|
82
|
+
rescue ::I18n::InvalidPluralizationData => e
|
|
83
|
+
e.entry[:other]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 1.0 -> 1, so custom pluralization rules can match integers.
|
|
87
|
+
def whole(count)
|
|
88
|
+
count.frac.zero? ? count.to_i : count
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Rewrites a plain "-1234.50" style number with the locale's characters.
|
|
92
|
+
# @param str [String] output of `format("%.Nf", value)`
|
|
93
|
+
# @return [String]
|
|
94
|
+
def apply(str, locale)
|
|
95
|
+
fmt = number_format(locale)
|
|
96
|
+
sign = str.start_with?("-") ? "-" : ""
|
|
97
|
+
int, frac = str.delete_prefix("-").split(".", 2)
|
|
98
|
+
int = int.reverse.scan(/\d{1,3}/).join(fmt[:delimiter].reverse).reverse
|
|
99
|
+
"#{sign}#{int}#{frac ? "#{fmt[:separator]}#{frac}" : ''}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
class Amount
|
|
4
4
|
class Registry
|
|
5
|
-
# Manages registry-driven constructor methods such as `Amount.
|
|
5
|
+
# Manages registry-driven constructor methods such as `Amount.of_usdc(...)`.
|
|
6
|
+
#
|
|
7
|
+
# Generated names are prefixed with `of_` so that short ISO codes like
|
|
8
|
+
# `:TRY` (which collides with `Object#try` once ActiveSupport is loaded)
|
|
9
|
+
# do not clash with existing methods on `Amount`.
|
|
6
10
|
class GeneratedConstructors
|
|
7
|
-
|
|
11
|
+
SYMBOL_PATTERN = /\A[a-z_][a-z0-9_]*\z/
|
|
12
|
+
METHOD_NAME_PREFIX = "of_"
|
|
8
13
|
|
|
9
14
|
def initialize(target: Amount)
|
|
10
15
|
@target = target
|
|
@@ -40,10 +45,10 @@ class Amount
|
|
|
40
45
|
private
|
|
41
46
|
|
|
42
47
|
def method_name_for(symbol)
|
|
43
|
-
|
|
44
|
-
return unless
|
|
48
|
+
downcased = symbol.to_s.downcase
|
|
49
|
+
return unless downcased.match?(SYMBOL_PATTERN)
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
"#{METHOD_NAME_PREFIX}#{downcased}"
|
|
47
52
|
end
|
|
48
53
|
|
|
49
54
|
def raise_collision!(method_name)
|
data/lib/amount/registry.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal"
|
|
4
4
|
require "thread"
|
|
5
|
-
require_relative "registry/generated_constructors"
|
|
6
5
|
|
|
7
6
|
class Amount
|
|
8
7
|
# Stores registered amount types and default directional conversion rates.
|
|
@@ -25,19 +24,33 @@ class Amount
|
|
|
25
24
|
class RegistryLocked < StandardError; end
|
|
26
25
|
class AlreadyRegistered < StandardError; end
|
|
27
26
|
class InvalidDisplayUnit < StandardError; end
|
|
27
|
+
class InvalidDisplaySymbol < StandardError; end
|
|
28
28
|
|
|
29
29
|
Entry = Struct.new(
|
|
30
30
|
:symbol,
|
|
31
31
|
:decimals,
|
|
32
32
|
:display_symbol,
|
|
33
|
+
:label_key,
|
|
33
34
|
:display_position,
|
|
35
|
+
:display_format,
|
|
34
36
|
:ui_decimals,
|
|
35
37
|
:display_units,
|
|
36
38
|
:default_display,
|
|
37
39
|
:amount_class,
|
|
38
40
|
:trim_zeros,
|
|
39
41
|
keyword_init: true
|
|
40
|
-
)
|
|
42
|
+
) do
|
|
43
|
+
# @param unit [Symbol, String]
|
|
44
|
+
# @return [Hash] the display unit's spec
|
|
45
|
+
# @raise [InvalidDisplayUnit] when the type has no such display unit
|
|
46
|
+
def display_unit(unit)
|
|
47
|
+
raise InvalidDisplayUnit, "#{symbol} has no display_units configured" unless display_units
|
|
48
|
+
|
|
49
|
+
display_units.fetch(unit.to_sym) do
|
|
50
|
+
raise InvalidDisplayUnit, "unknown display unit #{unit} for #{symbol}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
41
54
|
|
|
42
55
|
def initialize
|
|
43
56
|
@entries = {}
|
|
@@ -51,13 +64,25 @@ class Amount
|
|
|
51
64
|
# Registers a new fungible type.
|
|
52
65
|
#
|
|
53
66
|
# When the symbol is a valid Ruby method name after downcasing, an
|
|
54
|
-
# ergonomic constructor is also generated on `Amount
|
|
55
|
-
# `Amount.
|
|
67
|
+
# ergonomic constructor is also generated on `Amount` with an `of_`
|
|
68
|
+
# prefix, such as `Amount.of_usdc("1.50")`. The prefix avoids collisions
|
|
69
|
+
# with existing methods like `Object#try` (added by ActiveSupport).
|
|
56
70
|
#
|
|
57
71
|
# @param symbol [Symbol, String] registered type identifier
|
|
58
72
|
# @param decimals [Integer] number of storage decimals
|
|
59
|
-
# @param display_symbol [String] symbol used by UI helpers
|
|
60
|
-
#
|
|
73
|
+
# @param display_symbol [String, Hash] symbol used by UI helpers. A hash
|
|
74
|
+
# (`{ one: "search", other: "searches" }`) gives plural forms and must
|
|
75
|
+
# have `:other`
|
|
76
|
+
# @param label_key [String, Symbol, nil] I18n key for the unit label, used
|
|
77
|
+
# when localizing. Defaults to `amounts.units.<symbol downcased>`;
|
|
78
|
+
# a missing translation falls back to `display_symbol`
|
|
79
|
+
# @param display_position [Symbol, nil] `:prefix` (`"$1.50"`) or `:suffix`
|
|
80
|
+
# (`"1.50 SOL"`, the default). Shorthand for the templates `"%u%n"` and
|
|
81
|
+
# `"%n %u"`; can't be combined with `display_format`
|
|
82
|
+
# @param display_format [String, nil] template for decorated `ui` output,
|
|
83
|
+
# where `%n` is the formatted number (sign included) and `%u` the label,
|
|
84
|
+
# e.g. `"%n\u00A0%u"`. Used exactly as given. When localizing,
|
|
85
|
+
# `amounts.formats.<symbol downcased>` overrides it
|
|
61
86
|
# @param ui_decimals [Integer] decimals displayed by default UI formatting
|
|
62
87
|
# @param display_units [Hash, nil] optional display-only scaling definitions
|
|
63
88
|
# @param default_display [Symbol, nil] optional default display unit key
|
|
@@ -67,14 +92,16 @@ class Amount
|
|
|
67
92
|
# @raise [AlreadyRegistered] if the symbol is already registered or the
|
|
68
93
|
# generated constructor would collide with an existing method
|
|
69
94
|
# @raise [RegistryLocked] if the registry has been locked
|
|
95
|
+
# @raise [ArgumentError] if both `display_format` and `display_position`
|
|
96
|
+
# are given, or `display_format` is not a String containing `%n`
|
|
70
97
|
# @example
|
|
71
98
|
# Amount.register :USDC,
|
|
72
99
|
# decimals: 6,
|
|
73
100
|
# display_symbol: "$",
|
|
74
101
|
# display_position: :prefix,
|
|
75
102
|
# ui_decimals: 2
|
|
76
|
-
def register(symbol, decimals:, display_symbol: symbol.to_s, display_position:
|
|
77
|
-
ui_decimals: decimals, display_units: nil, default_display: nil,
|
|
103
|
+
def register(symbol, decimals:, display_symbol: symbol.to_s, label_key: nil, display_position: nil,
|
|
104
|
+
display_format: nil, ui_decimals: decimals, display_units: nil, default_display: nil,
|
|
78
105
|
trim_zeros: false, class: nil)
|
|
79
106
|
raise ArgumentError, "symbol must not be blank" if symbol.nil? || symbol.to_s.empty?
|
|
80
107
|
|
|
@@ -84,13 +111,17 @@ class Amount
|
|
|
84
111
|
ensure_unlocked!
|
|
85
112
|
raise AlreadyRegistered, "#{symbol} already registered" if @entries.key?(symbol)
|
|
86
113
|
|
|
114
|
+
validate_display_symbol!(:display_symbol, display_symbol, InvalidDisplaySymbol)
|
|
115
|
+
validate_display_format!(display_format, display_position) if display_format
|
|
87
116
|
validate_display_units!(display_units, default_display) if display_units
|
|
88
117
|
|
|
89
118
|
entry = Entry.new(
|
|
90
119
|
symbol:,
|
|
91
120
|
decimals:,
|
|
92
121
|
display_symbol:,
|
|
93
|
-
|
|
122
|
+
label_key: label_key&.to_s,
|
|
123
|
+
display_position: display_format ? nil : (display_position || :suffix),
|
|
124
|
+
display_format: display_format || Formattable.template_for(display_position),
|
|
94
125
|
ui_decimals:,
|
|
95
126
|
display_units:,
|
|
96
127
|
default_display:,
|
|
@@ -229,6 +260,13 @@ class Amount
|
|
|
229
260
|
raise RegistryLocked, "registry is locked" if @locked
|
|
230
261
|
end
|
|
231
262
|
|
|
263
|
+
def validate_display_format!(format, position)
|
|
264
|
+
raise ArgumentError, "pass display_format or display_position, not both" if position
|
|
265
|
+
return if Formattable.valid_template?(format)
|
|
266
|
+
|
|
267
|
+
raise ArgumentError, "display_format must be a String containing %n"
|
|
268
|
+
end
|
|
269
|
+
|
|
232
270
|
def validate_display_units!(units, default)
|
|
233
271
|
unless units.is_a?(Hash) && !units.empty?
|
|
234
272
|
raise InvalidDisplayUnit, "display_units must be a non-empty hash"
|
|
@@ -242,7 +280,32 @@ class Amount
|
|
|
242
280
|
unless spec.is_a?(Hash) && spec.key?(:scale)
|
|
243
281
|
raise InvalidDisplayUnit, "display_unit #{key} must have :scale"
|
|
244
282
|
end
|
|
283
|
+
|
|
284
|
+
validate_unit_format!(key, spec) if spec.key?(:format)
|
|
285
|
+
validate_display_symbol!("display_unit #{key} :symbol", spec[:symbol], InvalidDisplayUnit) if spec[:symbol]
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def validate_unit_format!(key, spec)
|
|
290
|
+
if spec.key?(:position)
|
|
291
|
+
raise InvalidDisplayUnit, "display_unit #{key} takes :format or :position, not both"
|
|
245
292
|
end
|
|
293
|
+
return if Formattable.valid_template?(spec[:format])
|
|
294
|
+
|
|
295
|
+
raise InvalidDisplayUnit, "display_unit #{key} :format must be a String containing %n"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def validate_display_symbol!(name, value, error)
|
|
299
|
+
return unless value.is_a?(Hash)
|
|
300
|
+
|
|
301
|
+
unless value.key?(:other)
|
|
302
|
+
raise error, "#{name} hash must have an :other key (got #{value.keys.inspect})"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
bad = value.reject { |k, v| k.is_a?(Symbol) && v.is_a?(String) }
|
|
306
|
+
return if bad.empty?
|
|
307
|
+
|
|
308
|
+
raise error, "#{name} hash must map Symbol plural keys to String labels (got #{bad.inspect})"
|
|
246
309
|
end
|
|
247
310
|
end
|
|
248
311
|
end
|
data/lib/amount/rspec.rb
CHANGED
|
@@ -12,10 +12,10 @@ require_relative "rspec/matchers"
|
|
|
12
12
|
# @example
|
|
13
13
|
# require "amount/rspec"
|
|
14
14
|
#
|
|
15
|
-
# expect(Amount.
|
|
16
|
-
# expect(Amount.
|
|
17
|
-
# expect(Amount.
|
|
18
|
-
# expect(Amount.
|
|
15
|
+
# expect(Amount.of_usdc("1.50")).to eq_amount("USDC|1.50")
|
|
16
|
+
# expect(Amount.of_usdc("1.50")).to be_amount_of(:USDC)
|
|
17
|
+
# expect(Amount.of_usdc("1.50")).to be_positive_amount
|
|
18
|
+
# expect(Amount.of_usdc("1.55")).to be_approximately_amount(:USDC, "1.50", within: "0.10")
|
|
19
19
|
Amount::RSpec::Matchers.define_amount_equality_matcher(:eq_amount) do |*expected_arguments|
|
|
20
20
|
Amount::RSpec::Support.coerce_amount_arguments(expected_arguments)
|
|
21
21
|
end
|