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