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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +37 -0
  3. data/CHANGELOG.md +142 -1
  4. data/Gemfile +4 -0
  5. data/README.md +218 -4
  6. data/Rakefile +47 -10
  7. data/lib/amount/active_record/amount_validator.rb +9 -18
  8. data/lib/amount/active_record/attribute_definition.rb +3 -1
  9. data/lib/amount/active_record/locale/en.yml +7 -0
  10. data/lib/amount/active_record/model.rb +9 -0
  11. data/lib/amount/active_record/railtie.rb +1 -1
  12. data/lib/amount/active_record.rb +12 -0
  13. data/lib/amount/compact.rb +137 -0
  14. data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
  15. data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +9 -9
  16. data/lib/amount/concerns/coercible.rb +65 -0
  17. data/lib/amount/concerns/constructible.rb +92 -0
  18. data/lib/amount/{conversion.rb → concerns/convertible.rb} +3 -3
  19. data/lib/amount/concerns/formattable.rb +112 -0
  20. data/lib/amount/concerns/localizable.rb +39 -0
  21. data/lib/amount/{comparison.rb → concerns/orderable.rb} +10 -10
  22. data/lib/amount/concerns/parsable.rb +58 -0
  23. data/lib/amount/concerns/registrable.rb +80 -0
  24. data/lib/amount/{serialization.rb → concerns/serializable.rb} +4 -4
  25. data/lib/amount/display.rb +86 -51
  26. data/lib/amount/locale.rb +102 -0
  27. data/lib/amount/registry/generated_constructors.rb +10 -5
  28. data/lib/amount/registry.rb +72 -9
  29. data/lib/amount/rspec.rb +4 -4
  30. data/lib/amount/ui_parser.rb +109 -0
  31. data/lib/amount/version.rb +1 -1
  32. data/lib/amount.rb +61 -205
  33. data/test/amount/active_record/amount_validator_test.rb +201 -0
  34. data/test/amount/active_record/attribute_definition_test.rb +234 -0
  35. data/test/amount/active_record/migration_methods_test.rb +60 -0
  36. data/test/amount/active_record/model_test.rb +40 -0
  37. data/test/amount/active_record/type_test.rb +80 -0
  38. data/test/amount/active_record_test.rb +12 -0
  39. data/test/amount/compact_test.rb +371 -0
  40. data/test/amount/concerns/allocatable_test.rb +166 -0
  41. data/test/amount/concerns/calculable_test.rb +152 -0
  42. data/test/amount/concerns/coercible_test.rb +49 -0
  43. data/test/amount/concerns/constructible_test.rb +113 -0
  44. data/test/amount/concerns/convertible_test.rb +54 -0
  45. data/test/amount/concerns/formattable_test.rb +336 -0
  46. data/test/amount/concerns/localizable_test.rb +59 -0
  47. data/test/amount/concerns/orderable_test.rb +83 -0
  48. data/test/amount/concerns/parsable_test.rb +98 -0
  49. data/test/amount/concerns/registrable_test.rb +67 -0
  50. data/test/amount/concerns/serializable_test.rb +98 -0
  51. data/test/amount/display_test.rb +181 -0
  52. data/test/amount/locale_test.rb +590 -0
  53. data/test/amount/parser_test.rb +29 -0
  54. data/test/amount/registry/generated_constructors_test.rb +94 -0
  55. data/test/amount/registry_test.rb +149 -0
  56. data/test/amount/ui_parser_test.rb +284 -0
  57. data/test/amount_test.rb +55 -0
  58. data/test/dummy/config/application.rb +3 -0
  59. data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
  60. data/test/integration/postgresql_test.rb +94 -0
  61. data/test/support/active_record_test_support.rb +65 -0
  62. data/test/support/error_helpers.rb +22 -0
  63. data/test/support/locale_test_setup.rb +36 -0
  64. data/test/test_helper.rb +1 -0
  65. metadata +47 -11
  66. data/test/postgresql_integration_test.rb +0 -71
  67. data/test/test_active_record.rb +0 -375
  68. data/test/test_amount.rb +0 -663
  69. 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 Serialization` does both halves:
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 Serialization
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 Serialization`.
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.usdc("1.50").to_h
44
+ # Amount.of_usdc("1.50").to_h
45
45
  # # => { v: 1, atomic: "1500000", symbol: "USDC" }
46
46
  def to_h
47
47
  {
@@ -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.usdc("1.50").ui # => "$1.50"
31
- # Amount.usdc("1.50").ui(decorated: false) # => "1.50"
32
- # Amount.gold("1").ui(unit: :gram) # => "31.10 g"
33
- # Amount.gold("1").ui(unit: :gram, decorated: false) # => "31.10"
34
- # Amount.sol("2.5").ui(trim_zeros: true) # => "2.5 SOL"
35
- def ui(unit: nil, direction: :floor, decorated: true, trim_zeros: nil)
36
- if unit
37
- render_display_unit(unit, direction, decorated:, trim_zeros:)
38
- else
39
- render_default(direction, decorated:, trim_zeros:)
40
- end
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 = fetch_display_unit(unit)
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
- def render_default(direction, decorated:, trim_zeros:)
59
- rounded = round(@amount.decimal, @entry.ui_decimals, direction)
60
- formatted = format_number(rounded, @entry.ui_decimals, resolve_trim(trim_zeros))
61
- return formatted unless decorated
62
-
63
- apply_symbol(formatted, @entry.display_symbol, @entry.display_position)
64
- end
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
- apply_symbol(
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 fetch_display_unit(unit)
82
- units = @entry.display_units
83
- unless units
84
- raise Registry::InvalidDisplayUnit, "#{@entry.symbol} has no display_units configured"
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
- units.fetch(unit.to_sym) do
88
- raise Registry::InvalidDisplayUnit, "unknown display unit #{unit} for #{@entry.symbol}"
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
- factor = BigDecimal(10)**decimals
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
- trim ? str.sub(/(\.\d*?)0+\z/, '\1').chomp('.') : str
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.usdc(...)`.
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
- METHOD_NAME_PATTERN = /\A[a-z_][a-z0-9_]*\z/
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
- method_name = symbol.to_s.downcase
44
- return unless method_name.match?(METHOD_NAME_PATTERN)
48
+ downcased = symbol.to_s.downcase
49
+ return unless downcased.match?(SYMBOL_PATTERN)
45
50
 
46
- method_name
51
+ "#{METHOD_NAME_PREFIX}#{downcased}"
47
52
  end
48
53
 
49
54
  def raise_collision!(method_name)
@@ -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`, such as
55
- # `Amount.usdc("1.50")`.
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
- # @param display_position [Symbol] either `:prefix` or `:suffix`
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: :suffix,
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
- display_position:,
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.usdc("1.50")).to eq_amount("USDC|1.50")
16
- # expect(Amount.usdc("1.50")).to be_amount_of(:USDC)
17
- # expect(Amount.usdc("1.50")).to be_positive_amount
18
- # expect(Amount.usdc("1.55")).to be_approximately_amount(:USDC, "1.50", within: "0.10")
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