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
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,6 +38,15 @@ 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
52
|
# Amount.of_usdc("1.50").ui # => "$1.50"
|
|
@@ -32,12 +54,45 @@ class Amount
|
|
|
32
54
|
# Amount.of_gold("1").ui(unit: :gram) # => "31.10 g"
|
|
33
55
|
# Amount.of_gold("1").ui(unit: :gram, decorated: false) # => "31.10"
|
|
34
56
|
# Amount.of_sol("2.5").ui(trim_zeros: true) # => "2.5 SOL"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
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 = {}
|
|
@@ -57,8 +70,19 @@ class Amount
|
|
|
57
70
|
#
|
|
58
71
|
# @param symbol [Symbol, String] registered type identifier
|
|
59
72
|
# @param decimals [Integer] number of storage decimals
|
|
60
|
-
# @param display_symbol [String] symbol used by UI helpers
|
|
61
|
-
#
|
|
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
|
|
62
86
|
# @param ui_decimals [Integer] decimals displayed by default UI formatting
|
|
63
87
|
# @param display_units [Hash, nil] optional display-only scaling definitions
|
|
64
88
|
# @param default_display [Symbol, nil] optional default display unit key
|
|
@@ -68,14 +92,16 @@ class Amount
|
|
|
68
92
|
# @raise [AlreadyRegistered] if the symbol is already registered or the
|
|
69
93
|
# generated constructor would collide with an existing method
|
|
70
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`
|
|
71
97
|
# @example
|
|
72
98
|
# Amount.register :USDC,
|
|
73
99
|
# decimals: 6,
|
|
74
100
|
# display_symbol: "$",
|
|
75
101
|
# display_position: :prefix,
|
|
76
102
|
# ui_decimals: 2
|
|
77
|
-
def register(symbol, decimals:, display_symbol: symbol.to_s, display_position:
|
|
78
|
-
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,
|
|
79
105
|
trim_zeros: false, class: nil)
|
|
80
106
|
raise ArgumentError, "symbol must not be blank" if symbol.nil? || symbol.to_s.empty?
|
|
81
107
|
|
|
@@ -85,13 +111,17 @@ class Amount
|
|
|
85
111
|
ensure_unlocked!
|
|
86
112
|
raise AlreadyRegistered, "#{symbol} already registered" if @entries.key?(symbol)
|
|
87
113
|
|
|
114
|
+
validate_display_symbol!(:display_symbol, display_symbol, InvalidDisplaySymbol)
|
|
115
|
+
validate_display_format!(display_format, display_position) if display_format
|
|
88
116
|
validate_display_units!(display_units, default_display) if display_units
|
|
89
117
|
|
|
90
118
|
entry = Entry.new(
|
|
91
119
|
symbol:,
|
|
92
120
|
decimals:,
|
|
93
121
|
display_symbol:,
|
|
94
|
-
|
|
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),
|
|
95
125
|
ui_decimals:,
|
|
96
126
|
display_units:,
|
|
97
127
|
default_display:,
|
|
@@ -230,6 +260,13 @@ class Amount
|
|
|
230
260
|
raise RegistryLocked, "registry is locked" if @locked
|
|
231
261
|
end
|
|
232
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
|
+
|
|
233
270
|
def validate_display_units!(units, default)
|
|
234
271
|
unless units.is_a?(Hash) && !units.empty?
|
|
235
272
|
raise InvalidDisplayUnit, "display_units must be a non-empty hash"
|
|
@@ -243,7 +280,32 @@ class Amount
|
|
|
243
280
|
unless spec.is_a?(Hash) && spec.key?(:scale)
|
|
244
281
|
raise InvalidDisplayUnit, "display_unit #{key} must have :scale"
|
|
245
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"
|
|
246
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})"
|
|
247
309
|
end
|
|
248
310
|
end
|
|
249
311
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Amount
|
|
4
|
+
# Internal: parses the text a person types into a form field, optionally in
|
|
5
|
+
# a locale's number format. Exact: never rounds, never goes through Float.
|
|
6
|
+
class UiParser
|
|
7
|
+
# @param input [#to_s]
|
|
8
|
+
# @param symbol [Symbol, String]
|
|
9
|
+
# @param locale [Symbol, String, nil]
|
|
10
|
+
def initialize(input, symbol, locale)
|
|
11
|
+
@input = input
|
|
12
|
+
@entry = Amount.registry.lookup(symbol)
|
|
13
|
+
@locale = Locale.effective(locale)
|
|
14
|
+
format = @locale ? Locale.number_format(@locale) : { delimiter: "", separator: "." }
|
|
15
|
+
@delimiter = format[:delimiter].to_s
|
|
16
|
+
@separator = format[:separator].to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Amount]
|
|
20
|
+
def parse
|
|
21
|
+
text = strip(@input.to_s)
|
|
22
|
+
fail_with("is empty") if text.empty?
|
|
23
|
+
original = text
|
|
24
|
+
|
|
25
|
+
sign, text = take_sign(text)
|
|
26
|
+
@signed = text != original
|
|
27
|
+
unlabeled = strip_label(text)
|
|
28
|
+
# "$-1.50": a sign may follow a prefix label, but not as a second sign
|
|
29
|
+
sign, unlabeled = take_sign(unlabeled) if sign.empty? && unlabeled != text && !@signed
|
|
30
|
+
text = unlabeled
|
|
31
|
+
value = atomic(text)
|
|
32
|
+
Amount.new(sign.empty? ? value : -value, @entry.symbol)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def fail_with(reason)
|
|
38
|
+
raise InvalidInput, "cannot parse #{@input.inspect} as #{@entry.symbol}: #{reason}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def strip(str)
|
|
42
|
+
str.gsub(/\A[[:space:]]+|[[:space:]]+\z/, "")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Array(String, String)] "-" or "" (a "+" is dropped), and the rest
|
|
46
|
+
def take_sign(text)
|
|
47
|
+
match = text.match(/\A([+-])[[:space:]]*(.*)\z/m)
|
|
48
|
+
return ["", text] unless match
|
|
49
|
+
|
|
50
|
+
[match[1] == "-" ? "-" : "", match[2]]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Removes one label (the longest match) from either end.
|
|
54
|
+
def strip_label(text)
|
|
55
|
+
labels.each do |label|
|
|
56
|
+
if text.start_with?(label)
|
|
57
|
+
return strip(text.delete_prefix(label))
|
|
58
|
+
elsif text.end_with?(label)
|
|
59
|
+
return strip(text.delete_suffix(label))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
text
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Every form of the type's label: `display_symbol` (and its plural
|
|
66
|
+
# forms), plus the locale's translation when localizing.
|
|
67
|
+
def labels
|
|
68
|
+
found = forms(@entry.display_symbol)
|
|
69
|
+
found += forms(translation) if @locale
|
|
70
|
+
found.map(&:to_s).reject(&:empty?).uniq.sort_by { |label| -label.length }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def translation
|
|
74
|
+
key = @entry.label_key || "amounts.units.#{@entry.symbol.to_s.downcase}"
|
|
75
|
+
::I18n.t(key.to_s, locale: @locale, default: nil)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def forms(label)
|
|
79
|
+
case label
|
|
80
|
+
when Hash then label.values.grep(String)
|
|
81
|
+
when String then [label]
|
|
82
|
+
else []
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Integer] atomic units, unsigned
|
|
87
|
+
def atomic(text)
|
|
88
|
+
whole, fraction, extra = text.split(@separator, -1)
|
|
89
|
+
fail_with("a decimal separator is used twice") if extra
|
|
90
|
+
fail_with("is not a number") unless whole&.match?(whole_pattern)
|
|
91
|
+
fraction ||= ""
|
|
92
|
+
fail_with("is not a number") if text.include?(@separator) && fraction.empty?
|
|
93
|
+
fail_with("is not a number") unless fraction.match?(/\A\d*\z/)
|
|
94
|
+
if fraction.length > @entry.decimals
|
|
95
|
+
fail_with("has more than #{@entry.decimals} decimal places")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
digits = whole.gsub(/\D/, "") + fraction.ljust(@entry.decimals, "0")
|
|
99
|
+
digits.to_i
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def whole_pattern
|
|
103
|
+
return /\A\d+\z/ if @delimiter.empty?
|
|
104
|
+
|
|
105
|
+
glue = @delimiter.match?(/\A[[:space:]]+\z/) ? "[[:space:]]" : Regexp.escape(@delimiter)
|
|
106
|
+
/\A(?:\d+|\d{1,3}(?:#{glue}\d{3})+)\z/
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/amount/version.rb
CHANGED