whittaker_tech-midas 0.1.1 → 0.3.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +75 -6
  3. data/app/controllers/whittaker_tech/midas/application_controller.rb +1 -5
  4. data/app/helpers/whittaker_tech/midas/application_helper.rb +1 -5
  5. data/app/helpers/whittaker_tech/midas/form_helper.rb +68 -72
  6. data/app/jobs/whittaker_tech/midas/application_job.rb +1 -5
  7. data/app/mailers/whittaker_tech/midas/application_mailer.rb +3 -7
  8. data/app/models/concerns/whittaker_tech/midas/bankable.rb +188 -172
  9. data/app/models/whittaker_tech/midas/application_record.rb +2 -6
  10. data/app/models/whittaker_tech/midas/coin/allocation.rb +124 -0
  11. data/app/models/whittaker_tech/midas/coin/arithmetic.rb +196 -0
  12. data/app/models/whittaker_tech/midas/coin/bidi.rb +87 -0
  13. data/app/models/whittaker_tech/midas/coin/converter/bank_provider.rb +35 -0
  14. data/app/models/whittaker_tech/midas/coin/converter.rb +79 -0
  15. data/app/models/whittaker_tech/midas/coin/parser.rb +104 -0
  16. data/app/models/whittaker_tech/midas/coin/presenter.rb +229 -0
  17. data/app/models/whittaker_tech/midas/coin.rb +285 -76
  18. data/app/models/whittaker_tech/midas/exchange.rb +37 -0
  19. data/db/migrate/20260101000000_create_whittaker_tech_schema.rb +26 -0
  20. data/db/migrate/{create_wt_midas_coins.rb → 20260101000001_create_midas_coins.rb} +7 -3
  21. data/db/migrate/20260219120000_rename_resource_label_to_resource_role_in_wt_midas_coins.rb +12 -0
  22. data/db/migrate/20260219150000_rename_wt_midas_coins_to_midas_coins.rb +13 -0
  23. data/db/migrate/20260707000000_create_midas_exchanges.rb +15 -0
  24. data/lib/generators/whittaker_tech/midas/install/install_generator.rb +5 -11
  25. data/lib/whittaker_tech/midas/deprecation.rb +32 -0
  26. data/lib/whittaker_tech/midas/engine.rb +113 -115
  27. data/lib/whittaker_tech/midas/version.rb +4 -4
  28. data/lib/whittaker_tech/midas.rb +120 -3
  29. metadata +72 -3
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Arithmetic defines the arithmetic and equality semantics of a Coin.
4
+ #
5
+ # ## Design invariants
6
+ #
7
+ # - **Immutable** — all operations return new, frozen `Coin` objects.
8
+ # - **Closed** — operations always return a `Coin`, never a primitive.
9
+ # - **Policy-aware only where unavoidable** — division accepts a rounding
10
+ # policy; all other operations are exact.
11
+ # - **Currency-strict** — binary operations (`+`, `-`, `==`) raise
12
+ # `ArgumentError` when the operands have different currency codes.
13
+ #
14
+ # ## Convenience division helpers
15
+ #
16
+ # For each key in `WhittakerTech::Midas::ROUNDING_POLICIES` a named shorthand
17
+ # is generated:
18
+ #
19
+ # coin.divide_round(3) # same as coin.divide(3, rounding_policy: :round)
20
+ # coin.divide_ceil(3)
21
+ # coin.divide_floor(3)
22
+ # coin.divide_bankers(3)
23
+ #
24
+ # @since 0.1.0
25
+ module WhittakerTech::Midas::Coin::Arithmetic
26
+ # Tests value equality with another Coin.
27
+ #
28
+ # Two Coins are equal when they have the same currency code **and** the
29
+ # same minor-unit amount. A Coin is never equal to a non-Coin object.
30
+ #
31
+ # @param other [Object] the object to compare
32
+ # @return [Boolean]
33
+ def ==(other)
34
+ other.is_a?(WhittakerTech::Midas::Coin) &&
35
+ currency_code == other.currency_code &&
36
+ currency_minor == other.currency_minor
37
+ end
38
+
39
+ # Value-based equality alias, compatible with `#hash`.
40
+ #
41
+ # Suitable for use in Sets and as Hash keys.
42
+ alias eql? ==
43
+
44
+ # Returns a hash value consistent with `#eql?`.
45
+ #
46
+ # Coins with the same currency code and minor-unit amount produce the same
47
+ # hash, making them interchangeable as Hash keys or Set members.
48
+ #
49
+ # @return [Integer]
50
+ def hash
51
+ [currency_code, currency_minor].hash
52
+ end
53
+
54
+ # Adds two Coins and returns the sum as a new Coin.
55
+ #
56
+ # @param other [Coin] must have the same currency code as the receiver
57
+ # @return [Coin] a new, frozen Coin
58
+ # @raise [TypeError] if `other` is not a `Coin`
59
+ # @raise [ArgumentError] if the currencies do not match
60
+ #
61
+ # @example
62
+ # a = Coin.value(1000, 'USD')
63
+ # b = Coin.value(500, 'USD')
64
+ # a + b # => Coin($15.00)
65
+ def +(other)
66
+ ensure_same_currency!(other)
67
+ WhittakerTech::Midas::Coin.value(currency_minor + other.currency_minor, currency_code).freeze
68
+ end
69
+
70
+ # Subtracts another Coin from the receiver and returns the difference.
71
+ #
72
+ # @param other [Coin] must have the same currency code as the receiver
73
+ # @return [Coin] a new, frozen Coin
74
+ # @raise [TypeError] if `other` is not a `Coin`
75
+ # @raise [ArgumentError] if the currencies do not match
76
+ #
77
+ # @example
78
+ # a = Coin.value(1000, 'USD')
79
+ # b = Coin.value(300, 'USD')
80
+ # a - b # => Coin($7.00)
81
+ def -(other)
82
+ ensure_same_currency!(other)
83
+ WhittakerTech::Midas::Coin.value(currency_minor - other.currency_minor, currency_code).freeze
84
+ end
85
+
86
+ # Multiplies the Coin by an integer scalar.
87
+ #
88
+ # @param other [Integer] the multiplier
89
+ # @return [Coin] a new, frozen Coin
90
+ # @raise [TypeError] if `other` is not an Integer
91
+ #
92
+ # @example
93
+ # Coin.value(500, 'USD') * 3 # => Coin($15.00)
94
+ def *(other)
95
+ raise TypeError unless other.is_a?(Integer)
96
+
97
+ WhittakerTech::Midas::Coin.value(currency_minor * other, currency_code).freeze
98
+ end
99
+
100
+ # Returns the remainder after dividing by an integer.
101
+ #
102
+ # @param other [Integer] the divisor
103
+ # @return [Coin] a new, frozen Coin representing the remainder
104
+ # @raise [TypeError] if `other` is not an Integer
105
+ # @raise [ZeroDivisionError] if `other` is zero
106
+ #
107
+ # @example
108
+ # Coin.value(1001, 'USD') % 3 # => Coin($0.02) (1001 % 3 == 2 cents)
109
+ def %(other)
110
+ raise TypeError unless other.is_a?(Integer)
111
+ raise ZeroDivisionError if other.zero?
112
+
113
+ WhittakerTech::Midas::Coin.value(currency_minor % other, currency_code).freeze
114
+ end
115
+
116
+ # Returns a new Coin with the sign reversed.
117
+ #
118
+ # @return [Coin] a new, frozen Coin
119
+ #
120
+ # @example
121
+ # Coin.value(500, 'USD').negate # => Coin(-$5.00)
122
+ def negate
123
+ WhittakerTech::Midas::Coin.value(-currency_minor, currency_code).freeze
124
+ end
125
+
126
+ # Unary minus operator — shorthand for `#negate`.
127
+ #
128
+ # @return [Coin]
129
+ def -@
130
+ negate
131
+ end
132
+
133
+ # Divides the Coin by a numeric divisor and returns the quotient.
134
+ #
135
+ # Division is the only arithmetic operation that requires a rounding policy
136
+ # because dividing integer minor units can produce a non-integer result.
137
+ #
138
+ # @param divisor [Numeric] the divisor; must be non-zero
139
+ # @param rounding_policy [Symbol] one of the keys in
140
+ # `WhittakerTech::Midas::ROUNDING_POLICIES` (default:
141
+ # `WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY`)
142
+ # @return [Coin] a new, frozen Coin
143
+ # @raise [TypeError] if `divisor` is not Numeric
144
+ # @raise [ZeroDivisionError] if `divisor` is zero
145
+ #
146
+ # @example
147
+ # Coin.value(1000, 'USD').divide(3, rounding_policy: :ceil) # => Coin($3.34)
148
+ # Coin.value(1000, 'USD').divide(3, rounding_policy: :floor) # => Coin($3.33)
149
+ # Coin.value(1000, 'USD').divide(3, rounding_policy: :bankers)# => Coin($3.33)
150
+ def divide(divisor, rounding_policy: WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY)
151
+ raise TypeError unless divisor.is_a?(Numeric)
152
+ raise ZeroDivisionError if divisor.zero?
153
+
154
+ mode = WhittakerTech::Midas::ROUNDING_POLICIES[rounding_policy]
155
+
156
+ unless mode
157
+ warn "Unknown rounding mode: #{rounding_policy.inspect}. Using default rounding mode."
158
+ mode = WhittakerTech::Midas::ROUNDING_POLICIES[
159
+ WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY
160
+ ]
161
+ end
162
+
163
+ raw = currency_minor.to_f / divisor
164
+ minor = mode.call(raw).to_i
165
+
166
+ WhittakerTech::Midas::Coin.value(minor, currency_code).freeze
167
+ end
168
+
169
+ # Convenience helpers generated from the rounding policy dictionary.
170
+ #
171
+ # Example:
172
+ # coin.divide_ceil(10)
173
+ # coin.divide_floor(3)
174
+ #
175
+ # These methods exist for semantic clarity and readability.
176
+ WhittakerTech::Midas::ROUNDING_POLICIES.each_key do |rounding_policy|
177
+ method_name = :"divide_#{rounding_policy}"
178
+
179
+ next if method_defined?(method_name)
180
+
181
+ define_method(method_name) do |divisor|
182
+ divide(divisor, rounding_policy: rounding_policy)
183
+ end
184
+ end
185
+
186
+ private
187
+
188
+ # Raises if `other` is not a `Coin` with the same currency code.
189
+ # @param other [Object]
190
+ # @raise [TypeError] if `other` is not a Coin
191
+ # @raise [ArgumentError] if currencies differ
192
+ def ensure_same_currency!(other)
193
+ raise TypeError unless other.is_a?(WhittakerTech::Midas::Coin)
194
+ raise ArgumentError, 'Currencies must match' unless currency_code == other.currency_code
195
+ end
196
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bidi provides Unicode bidirectional-text isolation helpers so that currency
4
+ # strings render correctly when embedded in mixed LTR/RTL sentences.
5
+ #
6
+ # ## Background
7
+ #
8
+ # In HTML, the Unicode Bidirectional Algorithm can reorder numeric and symbol
9
+ # characters in ways that produce confusing output when currency strings appear
10
+ # inside an RTL sentence (e.g. Arabic or Hebrew UI). Wrapping currency text in
11
+ # Unicode isolation marks prevents the algorithm from treating it as part of
12
+ # the surrounding paragraph direction.
13
+ #
14
+ # ## Isolation marks used
15
+ #
16
+ # Constants:
17
+ #
18
+ # - `LRI` (U+2066): Left-to-Right Isolate
19
+ # - `RLI` (U+2067): Right-to-Left Isolate
20
+ # - `PDI` (U+2069): Pop Directional Isolate (close)
21
+ #
22
+ # ## Direction configuration
23
+ #
24
+ # Currency direction defaults to `:ltr` for all currencies. Override per
25
+ # currency in an initializer:
26
+ #
27
+ # WhittakerTech::Midas.currency_directions['ILS'] = :rtl
28
+ # WhittakerTech::Midas.currency_directions['AED'] = :rtl
29
+ #
30
+ # See also: `WhittakerTech::Midas.currency_direction_for`
31
+ # @since 0.1.0
32
+ module WhittakerTech::Midas::Coin::Bidi
33
+ # Unicode Left-to-Right Isolate mark (U+2066)
34
+ LRI = "\u2066"
35
+
36
+ # Unicode Right-to-Left Isolate mark (U+2067)
37
+ RLI = "\u2067"
38
+
39
+ # Unicode Pop Directional Isolate mark — closes an isolation span (U+2069)
40
+ PDI = "\u2069"
41
+
42
+ # Wraps `text` in a Unicode directional-isolate span.
43
+ #
44
+ # @param text [String, nil] the text to isolate; `nil` returns `""`
45
+ # @param dir [Symbol, nil] `:ltr`, `:rtl`, or `nil` (no wrapping)
46
+ # @return [String]
47
+ # @raise [ArgumentError] if `dir` is not `:ltr`, `:rtl`, or `nil`
48
+ #
49
+ # @example
50
+ # bidi_isolate('$29.99', dir: :ltr) # => "\u2066$29.99\u2069"
51
+ # bidi_isolate('29.99', dir: nil) # => "29.99"
52
+ def bidi_isolate(text, dir:)
53
+ return '' if text.nil?
54
+
55
+ s = text.to_s
56
+ return s if dir.nil?
57
+
58
+ case dir
59
+ when :ltr then "#{LRI}#{s}#{PDI}"
60
+ when :rtl then "#{RLI}#{s}#{PDI}"
61
+ else
62
+ raise ArgumentError, "Unknown bidi dir: #{dir.inspect}"
63
+ end
64
+ end
65
+
66
+ # Wraps a number string with LTR isolation.
67
+ #
68
+ # Numbers are always rendered LTR. This prevents the bidi algorithm from
69
+ # reordering digit sequences when they appear in an RTL context.
70
+ #
71
+ # @param text [String, Integer, BigDecimal] the numeric value to isolate
72
+ # @return [String]
73
+ def bidi_isolate_number(text)
74
+ bidi_isolate(text, dir: :ltr)
75
+ end
76
+
77
+ # Resolves the configured display direction for a currency code.
78
+ #
79
+ # Reads from `WhittakerTech::Midas.currency_directions`. Defaults to
80
+ # `:ltr` for any currency not explicitly configured.
81
+ #
82
+ # @param currency_code [String] ISO 4217 currency code
83
+ # @return [Symbol] `:ltr` or `:rtl`
84
+ def bidi_currency_dir(currency_code)
85
+ WhittakerTech::Midas.currency_direction_for(currency_code)
86
+ end
87
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Default currency-conversion provider: wraps Money.default_bank (or any
4
+ # explicit Money::Bank::* instance) behind the adapter interface
5
+ # WhittakerTech::Midas::Coin::Converter expects.
6
+ #
7
+ # Adapter interface (duck-typed):
8
+ # #exchange(money, currency_code) -> Money
9
+ # #name -> String (recorded as Exchange#source)
10
+ # #exchange_at(money, currency_code, at:) -> Money (optional; only
11
+ # needed to support a non-default at:. This provider does not define
12
+ # it — the default Money::Bank::VariableExchange has no historical
13
+ # capability at all.)
14
+ #
15
+ # @since 0.3.0
16
+ class WhittakerTech::Midas::Coin::Converter::BankProvider
17
+ def initialize(bank = Money.default_bank)
18
+ @bank = bank
19
+ end
20
+
21
+ # @param money [Money] the value to convert (native currency)
22
+ # @param currency_code [String] target ISO 4217 code
23
+ # @return [Money]
24
+ def exchange(money, currency_code)
25
+ # NOTE: deliberately NOT `money.exchange_to(currency_code)` — that
26
+ # method hardcodes Money.default_bank internally (money gem 6.x) and
27
+ # would silently ignore a `using:` override. Call the bank directly.
28
+ @bank.exchange_with(money, Money::Currency.new(currency_code))
29
+ end
30
+
31
+ # @return [String] recorded on Exchange#source
32
+ def name
33
+ "money:#{@bank.class.name}"
34
+ end
35
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Converter implements cross-currency conversion for Coin, backed by a
4
+ # provider-agnostic adapter (default: Coin::Converter::BankProvider wrapping
5
+ # Money.default_bank). Every successful conversion writes an immutable
6
+ # WhittakerTech::Midas::Exchange audit row, which owns both a copy of the
7
+ # source value (`from`) and the converted result (`to`).
8
+ #
9
+ # @since 0.3.0
10
+ module WhittakerTech::Midas::Coin::Converter
11
+ # Converts this Coin to another currency.
12
+ #
13
+ # The receiver does not need to be persisted — it's only ever read, never
14
+ # mutated or reassigned. The result is a brand-new, persisted Coin owned
15
+ # by a newly created Exchange audit row (not linked back to this Coin's
16
+ # own resource).
17
+ #
18
+ # @param currency_code [String] target ISO 4217 currency code
19
+ # @param at [Time, nil] rate timestamp; nil (default) means "now". A
20
+ # non-nil value requires `using:` to supply a provider implementing
21
+ # `#exchange_at`.
22
+ # @param using [Object, Money::Bank::Base, nil] provider override; any
23
+ # object responding to `#exchange(money, currency_code)` and `#name`,
24
+ # or a raw `Money::Bank::*` instance (auto-wrapped in BankProvider)
25
+ # @return [Coin] the persisted `to` Coin (the converted result), owned by
26
+ # the newly created Exchange audit row
27
+ # @raise [ArgumentError] if `at` is given and the resolved provider has
28
+ # no `#exchange_at`
29
+ def convert_to(currency_code, at: nil, using: nil)
30
+ provider = resolve_provider(using)
31
+ iso = currency_code.to_s.strip.upcase
32
+
33
+ if currency_minor.zero?
34
+ converted_cents = 0
35
+ rate = BigDecimal(0)
36
+ else
37
+ converted = fetch_rate(provider, iso, at)
38
+ converted_cents = converted.cents
39
+ rate = BigDecimal(converted_cents) / BigDecimal(currency_minor)
40
+ end
41
+
42
+ persist_conversion(provider, iso, at, converted_cents, rate)
43
+ end
44
+ alias exchange_to convert_to
45
+
46
+ private
47
+
48
+ def resolve_provider(using)
49
+ case using
50
+ when nil then WhittakerTech::Midas::Coin::Converter::BankProvider.new
51
+ when Money::Bank::Base then WhittakerTech::Midas::Coin::Converter::BankProvider.new(using)
52
+ else using
53
+ end
54
+ end
55
+
56
+ def fetch_rate(provider, iso, at)
57
+ return provider.exchange(amount, iso) if at.nil?
58
+
59
+ unless provider.respond_to?(:exchange_at)
60
+ raise ArgumentError,
61
+ "#{provider.respond_to?(:name) ? provider.name : provider.class.name} does not support " \
62
+ 'historical rates (at:); omit at: or supply a provider implementing #exchange_at'
63
+ end
64
+
65
+ provider.exchange_at(amount, iso, at:)
66
+ end
67
+
68
+ def persist_conversion(provider, iso, at, minor, rate)
69
+ source = provider.respond_to?(:name) ? provider.name : provider.class.name
70
+ stamp = at || Time.current
71
+
72
+ WhittakerTech::Midas::Coin.transaction do
73
+ exchange = WhittakerTech::Midas::Exchange.create!(rate:, source:, at: stamp)
74
+ exchange.set_from(amount: currency_minor, currency_code: currency_code)
75
+ exchange.set_to(amount: minor, currency_code: iso)
76
+ exchange.to
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Parser coerces heterogeneous inputs into a `Coin`.
4
+ #
5
+ # Accepted input types:
6
+ #
7
+ # Accepted input types:
8
+ #
9
+ # - `Coin`: Returned as-is (no conversion)
10
+ # - `Money`: Wraps `money.cents` + `money.currency.iso_code` into a Coin
11
+ # - `Numeric`: Treated as a major-unit amount; `currency_code` required
12
+ # - `String`: Strips non-numeric characters; `currency_code` recommended
13
+ #
14
+ # ### Numeric vs. Integer semantics
15
+ #
16
+ # The Parser treats all Numeric inputs (including Integer) as **major units**
17
+ # (dollars, euros, etc.) and scales them to minor units using
18
+ # `Money.from_amount`. This differs from `Coin#amount=` and `Coin.value`,
19
+ # which expect minor units directly.
20
+ #
21
+ # If you already have cents use `Coin.value(2999, 'USD')` instead.
22
+ #
23
+ # @example
24
+ # Coin.parse(Money.new(2999, 'USD')) # => Coin(2999 USD)
25
+ # Coin.parse(29.99, currency_code: 'USD') # => Coin(2999 USD)
26
+ # Coin.parse('$29.99') # => Coin(2999 USD) (uses Money.default_currency)
27
+ # Coin.parse('29.99', currency_code: 'USD') # => Coin(2999 USD)
28
+ #
29
+ # @since 0.1.0
30
+ class WhittakerTech::Midas::Coin::Parser
31
+ class << self
32
+ # Parses a value into a `Coin`.
33
+ #
34
+ # @param value [Coin, Money, Numeric, String] the value to parse
35
+ # @param currency_code [String, nil] required for bare Numeric and plain
36
+ # String values
37
+ # @return [Coin]
38
+ # @raise [TypeError] if `value` is an unsupported type
39
+ # @raise [ArgumentError] if `currency_code` is required but not provided
40
+ def parse(value, currency_code: nil)
41
+ case value
42
+ when WhittakerTech::Midas::Coin
43
+ value
44
+ when Money
45
+ WhittakerTech::Midas::Coin.value(value.cents, value.currency.iso_code)
46
+ when Numeric
47
+ parse_numeric(value, currency_code)
48
+ when String
49
+ parse_string(value, currency_code)
50
+ else
51
+ raise TypeError, "Cannot convert #{value.class} to Coin"
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # Converts a Numeric major-unit amount to a Coin.
58
+ #
59
+ # @param value [Numeric] major-unit amount (e.g. `29.99` for $29.99)
60
+ # @param currency_code [String, nil]
61
+ # @return [Coin]
62
+ # @raise [ArgumentError] if `currency_code` is nil
63
+ def parse_numeric(value, currency_code)
64
+ raise ArgumentError, 'currency_code required' unless currency_code
65
+
66
+ money = Money.from_amount(value, currency_code)
67
+ WhittakerTech::Midas::Coin.value(money.cents, money.currency.iso_code)
68
+ end
69
+
70
+ # Converts a String amount to a Coin.
71
+ #
72
+ # If the string contains non-numeric, non-decimal characters (e.g. `$`)
73
+ # and no `currency_code` is given, `Money.default_currency` is used.
74
+ # A bare numeric string (e.g. `"29.99"`) without a `currency_code` raises.
75
+ #
76
+ # @param str [String]
77
+ # @param currency_code [String, nil]
78
+ # @return [Coin]
79
+ # @raise [ArgumentError] if the string has no embedded currency marker and
80
+ # `currency_code` is nil
81
+ def parse_string(str, currency_code)
82
+ stripped = str.strip
83
+
84
+ money =
85
+ if currency_code
86
+ Money.from_amount(extract_number(stripped), currency_code)
87
+ elsif stripped =~ /[^\d.\s]/
88
+ Money.from_amount(extract_number(stripped), Money.default_currency)
89
+ else
90
+ raise ArgumentError, 'Currency code required for string amounts'
91
+ end
92
+
93
+ WhittakerTech::Midas::Coin.value(money.cents, money.currency.iso_code)
94
+ end
95
+
96
+ # Strips all characters that are not digits or a decimal point.
97
+ #
98
+ # @param str [String]
99
+ # @return [BigDecimal]
100
+ def extract_number(str)
101
+ BigDecimal(str.gsub(/[^\d.]/, ''))
102
+ end
103
+ end
104
+ end