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