whittaker_tech-midas 0.1.0 → 0.2.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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -4
  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 +193 -174
  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.rb +32 -0
  14. data/app/models/whittaker_tech/midas/coin/parser.rb +104 -0
  15. data/app/models/whittaker_tech/midas/coin/presenter.rb +229 -0
  16. data/app/models/whittaker_tech/midas/coin.rb +314 -76
  17. data/db/migrate/20260101000000_create_whittaker_tech_schema.rb +26 -0
  18. data/db/migrate/{20251015160523_create_wt_midas_coins.rb → 20260101000001_create_midas_coins.rb} +7 -3
  19. data/db/migrate/20260219120000_rename_resource_label_to_resource_role_in_wt_midas_coins.rb +12 -0
  20. data/db/migrate/20260219150000_rename_wt_midas_coins_to_midas_coins.rb +13 -0
  21. data/lib/generators/whittaker_tech/midas/install/install_generator.rb +10 -0
  22. data/lib/tasks/whittaker_tech/midas_tasks.rake +27 -4
  23. data/lib/whittaker_tech/midas/deprecation.rb +32 -0
  24. data/lib/whittaker_tech/midas/engine.rb +113 -110
  25. data/lib/whittaker_tech/midas/version.rb +4 -4
  26. data/lib/whittaker_tech/midas.rb +120 -3
  27. metadata +71 -19
  28. data/config/routes.rb +0 -2
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Allocation represents a per-unit interpretation of a `Coin`.
4
+ #
5
+ # ## Conceptual model
6
+ #
7
+ # ```
8
+ # Coin -> canonical, payable money (what exists)
9
+ # Allocation -> "this Coin, spread across N units, using policy X"
10
+ # ```
11
+ #
12
+ # An Allocation answers two questions:
13
+ #
14
+ # 1. **What does one unit cost?** — `#value`
15
+ # 2. **What does a given quantity cost?** — `#price`
16
+ #
17
+ # ## Immutability
18
+ #
19
+ # Allocation is:
20
+ # - **immutable** — `@coin`, `@divisor`, and `@rounding_policy` are frozen on
21
+ # construction and never mutated.
22
+ # - **not persisted** — Allocation is a pure value object.
23
+ # - **minor-unit safe** — it never stores sub-minor-unit amounts; rounding is
24
+ # applied before returning a Coin.
25
+ #
26
+ # @example Per-unit price from a bulk price
27
+ # bulk = Coin.value(10_000, 'USD') # $100.00 for a pack of 6
28
+ # alloc = bulk.allocate(per: 6, rounding_policy: :ceil)
29
+ # alloc.value # => Coin($16.67)
30
+ # alloc.price(qty: 2) # => Coin($33.34)
31
+ #
32
+ # See also: `Coin#allocate`
33
+ # @since 0.1.0
34
+ class WhittakerTech::Midas::Coin::Allocation
35
+ # @return [Coin] the total monetary amount this Allocation is based on
36
+ attr_reader :coin
37
+
38
+ # @return [Numeric] the number of units the coin covers (the divisor)
39
+ attr_reader :divisor
40
+
41
+ # @return [Symbol] the rounding policy applied to per-unit calculations
42
+ attr_reader :rounding_policy
43
+
44
+ # Alias for `#divisor`. Reads as "this coin, per N units".
45
+ alias per divisor
46
+
47
+ # Delegate invariant facts from the underlying Coin.
48
+ # These values do not change under scaling.
49
+ delegate :currency_code,
50
+ :currency,
51
+ :decimals,
52
+ :scale,
53
+ :zero?,
54
+ :positive?,
55
+ :negative?,
56
+ to: :coin
57
+
58
+ # @param coin [Coin] the total monetary value
59
+ # @param divisor [Numeric] the number of units; must be positive
60
+ # @param rounding_policy [Symbol] one of `WhittakerTech::Midas::ROUNDING_POLICIES`
61
+ # @raise [TypeError] if `coin` is not a `Coin`
62
+ # @raise [TypeError] if `divisor` is not a positive Numeric
63
+ # @raise [ArgumentError] if `rounding_policy` is not a recognised policy key
64
+ def initialize(coin:, divisor:, rounding_policy:)
65
+ raise TypeError unless coin.is_a?(WhittakerTech::Midas::Coin)
66
+ raise TypeError unless divisor.is_a?(Numeric) && divisor.positive?
67
+ raise ArgumentError unless WhittakerTech::Midas::ROUNDING_POLICIES.key?(rounding_policy)
68
+
69
+ @coin = coin.freeze
70
+ @divisor = divisor
71
+ @rounding_policy = rounding_policy.freeze
72
+ end
73
+
74
+ # Returns the per-unit Coin after dividing by `#divisor` and applying the
75
+ # `#rounding_policy`.
76
+ #
77
+ # The result is still a `Coin` — payable money representing a single unit.
78
+ #
79
+ # @return [Coin]
80
+ def value
81
+ coin.divide(divisor, rounding_policy: rounding_policy)
82
+ end
83
+
84
+ # Returns the total price for a given quantity of units.
85
+ #
86
+ # Uses `(total_minor * qty) / divisor` rounded via `#rounding_policy`.
87
+ # Equivalent to `qty` units at the per-unit rate, but computed from the
88
+ # original total to minimise accumulated rounding error.
89
+ #
90
+ # @param qty [Numeric] the number of units; must be >= 0
91
+ # @return [Coin]
92
+ # @raise [ArgumentError] if `qty` is negative or not Numeric
93
+ #
94
+ # @example
95
+ # alloc = Coin.value(10_000, 'USD').allocate(per: 6)
96
+ # alloc.price(qty: 3) # => Coin($50.00) (10000 * 3 / 6 = 5000 cents)
97
+ def price(qty: 1)
98
+ raise ArgumentError unless qty.is_a?(Numeric) && qty >= 0
99
+
100
+ WhittakerTech::Midas::Coin.value(
101
+ round((coin.currency_minor * qty) / divisor),
102
+ coin.currency_code
103
+ ).freeze
104
+ end
105
+
106
+ # Human-readable representation for logs and console output.
107
+ # @return [String]
108
+ def inspect
109
+ "#<#{self.class.name} coin=#{coin.inspect} divisor=#{divisor} rounding_policy=#{rounding_policy}>"
110
+ end
111
+
112
+ private
113
+
114
+ # Applies the configured `#rounding_policy` to a raw Numeric amount and
115
+ # returns an Integer suitable for Coin construction.
116
+ # @param amount [Numeric]
117
+ # @return [Integer]
118
+ def round(amount)
119
+ WhittakerTech::Midas::ROUNDING_POLICIES
120
+ .fetch(rounding_policy)
121
+ .call(amount)
122
+ .to_i
123
+ end
124
+ end
@@ -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,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Converter is a reserved module for future currency conversion logic.
4
+ #
5
+ # It exists as a clear architectural placeholder to separate concerns:
6
+ #
7
+ # Related modules:
8
+ #
9
+ # - `Arithmetic`: Integer arithmetic on minor units
10
+ # - `Allocation`: Per-unit pricing interpretation
11
+ # - `Converter`: Cross-currency rate conversion (planned)
12
+ #
13
+ # When implemented, Converter will handle:
14
+ # - Exchange rate sources (live API, snapshot, historical)
15
+ # - Historical conversions with a specific timestamp
16
+ # - Regulatory rounding rules per jurisdiction
17
+ #
18
+ # @note All methods in this module raise `NotImplementedError` intentionally.
19
+ # Use the Money gem's exchange rate infrastructure directly for now.
20
+ # @since 0.1.0
21
+ module WhittakerTech::Midas::Coin::Converter
22
+ # Converts this Coin to another currency.
23
+ #
24
+ # @param currency_code [String] target ISO 4217 currency code
25
+ # @param at [Time] the rate timestamp (for historical conversions)
26
+ # @param using [Object, nil] exchange rate provider / bank override
27
+ # @return [Coin]
28
+ # @raise [NotImplementedError] — not yet implemented
29
+ def convert_to(currency_code, at: Time.current, using: nil)
30
+ raise NotImplementedError
31
+ end
32
+ 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