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,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Presenter provides a strftime-like token grammar for rendering `Coin` values.
4
+ #
5
+ # ## Design principles
6
+ #
7
+ # - **Declarative** — tokens map directly to named handler methods.
8
+ # - **Pure** — no mutation, rounding, or currency conversion.
9
+ # - **Direction-safe** — all text is wrapped with Unicode bidirectional
10
+ # isolation markers via `Coin::Bidi` to prevent display corruption in
11
+ # mixed LTR/RTL contexts.
12
+ #
13
+ # ## Token reference
14
+ #
15
+ # Patterns are strings containing `%x` tokens (similar to `strftime`).
16
+ #
17
+ # Tokens:
18
+ #
19
+ # - `%t`: Formatted total (symbol + amount), e.g. `$29.99`
20
+ # - `%m`: Minor units (raw integer), e.g. `2999`
21
+ # - `%M`: Major units (decimal string), e.g. `29.99`
22
+ # - `%c`: Currency code, e.g. `USD`
23
+ # - `%s`: Currency symbol, e.g. `$`
24
+ # - `%n`: Number only (no symbol), e.g. `29.99`
25
+ # - `%u`: Custom units label (see opts), e.g. `per kg`
26
+ # - `%p`: Custom per-exact label (see opts), e.g. `0.2997`
27
+ # - `%~`: Approximate marker (`≈` or empty)
28
+ # - `%%`: Literal percent sign
29
+ #
30
+ # ## Usage
31
+ #
32
+ # coin = Coin.value(2999, 'USD')
33
+ # coin.present('%s%M %c') # => "$29.99 USD"
34
+ # coin.present('%~%t', approx: true) # => "≈$29.99"
35
+ #
36
+ # ## Options
37
+ #
38
+ # Pass keyword options to `present` / `format` to populate optional tokens:
39
+ #
40
+ # - `approx:` [Boolean] — if true, `~` expands to `≈`; otherwise empty string.
41
+ # - `units:` [String] — value for `%u` token.
42
+ # - `per_exact:` [String] — value for `%p` token.
43
+ # - `currency_dir:` [Symbol] — override the currency display direction
44
+ # (`:ltr` or `:rtl`); defaults to the value from
45
+ # `WhittakerTech::Midas.currency_direction_for`.
46
+ #
47
+ # @since 0.1.0
48
+ module WhittakerTech::Midas::Coin::Presenter
49
+ # Internal rendering context. Populated from the Coin and caller-supplied options.
50
+ # @!visibility private
51
+ Context = Struct.new(
52
+ :coin,
53
+ :currency_dir,
54
+ :approx,
55
+ :units,
56
+ :per_exact,
57
+ keyword_init: true
58
+ )
59
+
60
+ # Renders this Coin using a format pattern.
61
+ #
62
+ # @param pattern [String] the format pattern containing `%x` tokens
63
+ # @param opts [Hash] optional rendering context overrides (see module docs)
64
+ # @return [String]
65
+ # @raise [ArgumentError] if the pattern contains an unknown or unterminated token
66
+ #
67
+ # @example
68
+ # coin.present('%s%M') # => "$29.99"
69
+ # coin.present('%t (%c)') # => "$29.99 (USD)"
70
+ # coin.present('~%t', approx: true) # => "≈$29.99"
71
+ def present(pattern, **)
72
+ WhittakerTech::Midas::Coin::Presenter.format(self, pattern, **)
73
+ end
74
+
75
+ class << self
76
+ # Registry of recognised tokens and their handler method names.
77
+ #
78
+ # @return [Hash{String => Symbol}]
79
+ TOKEN_MAP = {
80
+ '%' => :token_percent,
81
+ 't' => :token_total,
82
+ 'm' => :token_minor,
83
+ 'M' => :token_major,
84
+ 'c' => :token_currency_code,
85
+ 's' => :token_currency_symbol,
86
+ 'n' => :token_number_only,
87
+ 'u' => :token_units,
88
+ 'p' => :token_per_exact,
89
+ '~' => :token_approx
90
+ }.freeze
91
+
92
+ # Formats a Coin using a pattern string.
93
+ #
94
+ # This is the class-level entry point; instance-level access is via
95
+ # `Coin#present`.
96
+ #
97
+ # @param coin [Coin] the value to format
98
+ # @param pattern [String] the format pattern
99
+ # @param opts [Hash] optional context overrides
100
+ # @return [String]
101
+ # @raise [ArgumentError] if `pattern` is nil, contains an unknown token,
102
+ # or has an unterminated `%` escape
103
+ def format(coin, pattern, **)
104
+ raise ArgumentError, 'pattern required' if pattern.nil?
105
+
106
+ ctx = build_context(coin, **)
107
+ scan(pattern, ctx)
108
+ end
109
+
110
+ # Builds a `Context` struct from a Coin and caller-supplied options.
111
+ #
112
+ # @param coin [Coin]
113
+ # @param opts [Hash]
114
+ # @option opts [Symbol] :currency_dir (:ltr or :rtl) override direction
115
+ # @option opts [Boolean] :approx whether to render `~` as `≈`
116
+ # @option opts [String, nil] :units value for `%u` token
117
+ # @option opts [String, nil] :per_exact value for `%p` token
118
+ # @return [Context]
119
+ def build_context(coin, **opts)
120
+ Context.new(coin:,
121
+ currency_dir: opts[:currency_dir] || coin.bidi_currency_dir(coin.currency_code),
122
+ approx: opts[:approx] || false,
123
+ units: opts[:units] || nil,
124
+ per_exact: opts[:per_exact] || nil)
125
+ end
126
+
127
+ # Scans a pattern string, expanding `%x` tokens into rendered values.
128
+ #
129
+ # @param pattern [String]
130
+ # @param ctx [Context]
131
+ # @return [String]
132
+ # @raise [ArgumentError] on unknown or unterminated tokens
133
+ def scan(pattern, ctx)
134
+ is_token = false
135
+ out = +'' # output buffer
136
+
137
+ pattern.to_s.each_char do |char|
138
+ if is_token
139
+ out << dispatch(char, ctx)
140
+ is_token = false
141
+ elsif char == '%'
142
+ is_token = true
143
+ else
144
+ out << char
145
+ end
146
+ end
147
+
148
+ raise ArgumentError, "Unterminated token in pattern: #{pattern}" if is_token
149
+
150
+ out
151
+ end
152
+
153
+ # Dispatches a single token character to its handler.
154
+ #
155
+ # @param token [String] single character following `%`
156
+ # @param ctx [Context]
157
+ # @return [String]
158
+ # @raise [ArgumentError] if the token is not in `TOKEN_MAP`
159
+ def dispatch(token, ctx)
160
+ handler = TOKEN_MAP[token]
161
+ raise ArgumentError, "Unknown presenter token: %#{token}" unless handler
162
+
163
+ send(handler, **ctx.to_h)
164
+ end
165
+
166
+ # -------------------------
167
+ # Token implementations
168
+ # -------------------------
169
+
170
+ # @api private
171
+ def token_percent(**)
172
+ '%'
173
+ end
174
+
175
+ # @api private
176
+ def token_total(coin:, currency_dir:, **)
177
+ coin.bidi_isolate(coin.amount.format, dir: currency_dir)
178
+ end
179
+
180
+ # @api private
181
+ def token_minor(coin:, **)
182
+ coin.bidi_isolate_number(coin.currency_minor)
183
+ end
184
+
185
+ # @api private
186
+ def token_major(coin:, **)
187
+ coin.bidi_isolate_number(coin.major.to_s('F'))
188
+ end
189
+
190
+ # @api private
191
+ def token_currency_code(coin:, **)
192
+ # Currency codes are neutral; isolate as LTR for stability
193
+ coin.bidi_isolate(coin.currency_code, dir: :ltr)
194
+ end
195
+
196
+ # @api private
197
+ def token_currency_symbol(coin:, currency_dir:, **)
198
+ symbol = Money::Currency.new(coin.currency_code).symbol
199
+ coin.bidi_isolate(symbol, dir: currency_dir)
200
+ end
201
+
202
+ # @api private
203
+ def token_number_only(coin:, **)
204
+ # Best-effort extraction of the numeric portion
205
+ formatted = coin.amount.format
206
+ symbol = Money::Currency.new(coin.currency_code).symbol.to_s
207
+
208
+ numberish =
209
+ symbol.empty? ? formatted : formatted.gsub(symbol, '').strip
210
+
211
+ coin.bidi_isolate_number(numberish)
212
+ end
213
+
214
+ # @api private
215
+ def token_units(units:, **)
216
+ units.nil? ? '' : units.to_s
217
+ end
218
+
219
+ # @api private
220
+ def token_per_exact(per_exact:, **)
221
+ per_exact.nil? ? '' : per_exact.to_s
222
+ end
223
+
224
+ # @api private
225
+ def token_approx(approx:, **)
226
+ approx ? '≈' : ''
227
+ end
228
+ end
229
+ end
@@ -1,81 +1,290 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module WhittakerTech
4
- module Midas
5
- class Coin < ApplicationRecord
6
- self.table_name = 'wt_midas_coins'
7
-
8
- belongs_to :resource, polymorphic: true
9
-
10
- before_validation :normalize_fields
11
-
12
- validates :resource_label,
13
- presence: true,
14
- format: { with: /\A[a-z0-9_]+\z/ },
15
- length: { maximum: 64 },
16
- uniqueness: { scope: %i[resource_type resource_id], case_sensitive: false }
17
-
18
- validates :currency_code, presence: true, length: { is: 3 }
19
- validates :currency_minor, presence: true, numericality: { only_integer: true }
20
-
21
- # Returns a Money object representing the stored monetary value.
22
- # Memoized for performance in hot paths.
23
- def amount
24
- @amount ||= Money.new(currency_minor, currency_code)
25
- end
26
-
27
- # Sets the coin's monetary value from various input types.
28
- def amount=(value)
29
- case value
30
- when Money
31
- self.currency_minor = value.cents
32
- self.currency_code = value.currency.iso_code
33
- when Numeric
34
- raise ArgumentError, 'currency_code required before setting numeric amount' if currency_code.blank?
35
-
36
- self.currency_minor = Integer(value)
37
- else
38
- raise ArgumentError, "Invalid value for Coin#amount: #{value.inspect}"
39
- end
40
- end
41
-
42
- delegate :exchange_to, to: :amount
43
-
44
- def format(to: nil)
45
- (to ? exchange_to(to) : amount).format
46
- end
47
-
48
- # Convenient aliases for form helpers
49
- def minor
50
- currency_minor
51
- end
52
-
53
- def currency
54
- currency_code
55
- end
56
-
57
- def fractional
58
- currency_minor
59
- end
60
-
61
- # Override setters to clear memoization
62
- def currency_minor=(value)
63
- @amount = nil
64
- super
65
- end
66
-
67
- def currency_code=(value)
68
- @amount = nil
69
- super
70
- end
71
-
72
- private
73
-
74
- # Normalize inputs for consistency and case-insensitive uniqueness
75
- def normalize_fields
76
- self.resource_label = resource_label.to_s.strip.downcase.presence if resource_label
77
- self.currency_code = currency_code.to_s.strip.upcase.presence if currency_code
78
- end
3
+ # Coin is the canonical, persisted representation of a monetary value.
4
+ #
5
+ # ## Conceptual model
6
+ #
7
+ # - A Coin represents *payable money*: an exact integer count of minor units
8
+ # (cents, pence, etc.) in a specific currency.
9
+ # - A Coin is **immutable by convention**: arithmetic operations return new
10
+ # frozen Coins rather than mutating the receiver.
11
+ # - A Coin owns **arithmetic truth** — not presentation, pricing
12
+ # interpretation, or currency conversion.
13
+ # ## Storage
14
+ #
15
+ #
16
+ # Every Coin is persisted in `midas_coins` and belongs polymorphically to
17
+ # any domain object (`resource_type` / `resource_id`). The `resource_role`
18
+ # distinguishes multiple coins on the same resource (e.g. `"price"`,
19
+ # `"cost"`, `"tax"`).
20
+ #
21
+ # ## Modules
22
+ #
23
+ # Behavior is composed via mixins:
24
+ #
25
+ # +------------+----------------------------------------------+
26
+ # | Module | Responsibility |
27
+ # +------------+----------------------------------------------+
28
+ # | Arithmetic | +, -, *, /, %, negate, equality |
29
+ # | Bidi | Unicode bidirectional text isolation |
30
+ # | Converter | Currency conversion (live, audited via Exchange) |
31
+ # | Presenter | Token-based formatting grammar |
32
+ # +------------+----------------------------------------------+
33
+ #
34
+ # ## Usage via Bankable
35
+ #
36
+ # The typical entry point is the `WhittakerTech::Midas::Bankable` concern; direct Coin construction
37
+ # is mostly used in service objects and tests.
38
+ #
39
+ # product.set_price(amount: 29.99, currency_code: 'USD')
40
+ # product.price # => #<WhittakerTech::Midas::Coin ...>
41
+ # product.price_amount # => #<Money @fractional=2999 @currency="USD">
42
+ #
43
+ # See also:
44
+ #
45
+ # - `WhittakerTech::Midas::Bankable`
46
+ # - `WhittakerTech::Midas::Coin::Arithmetic`
47
+ # - `WhittakerTech::Midas::Coin::Allocation`
48
+ # @since 0.1.0
49
+ class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
50
+ # Arithmetic: exact arithmetic and equality semantics
51
+ include Arithmetic
52
+ # Bidi: bidirectional currency conversion
53
+ include Bidi
54
+ # Converter: live currency conversion + Exchange audit trail
55
+ include Converter
56
+ # Presenter: formatting and presentation logic
57
+ include Presenter
58
+
59
+ self.table_name = WhittakerTech::Midas.table_name('coins')
60
+
61
+ # Coins belong polymorphically to a domain object (ledger, entry, product, etc.)
62
+ belongs_to :resource, polymorphic: true
63
+
64
+ # Poly::Joins defines joins_resource(klass) for typed polymorphic joins
65
+ include Poly::Joins
66
+ # Poly::Role validates resource_role, normalizes it, and provides for_role scope
67
+ include Poly::Role
68
+ # Poly::Owners defines owner_resource(klass) for typed polymorphic ownership
69
+ include Poly::Owners
70
+
71
+ # Normalize user input before validation to ensure consistent storage
72
+ before_validation :normalize_currency_code
73
+
74
+ # Delegates presence, format `/\A[a-z0-9_]+\z/`, length (max 64), and
75
+ # before_validation normalization to Poly::Role
76
+ poly_role :resource
77
+
78
+ # Each resource may only have one Coin per resource_role
79
+ validates :resource_role,
80
+ uniqueness: { scope: %i[resource_type resource_id], case_sensitive: false }
81
+
82
+ # Currency code is stored as a 3-letter ISO string (e.g. "USD")
83
+ validates :currency_code, presence: true, length: { is: 3 }
84
+
85
+ # Minor units are always stored as integers
86
+ validates :currency_minor, presence: true, numericality: { only_integer: true }
87
+
88
+ # Returns a Money object representing the stored monetary value.
89
+ #
90
+ # This is a *projection*, not canonical value. Use `#currency_minor`
91
+ # and `#currency_code` as the source of truth.
92
+ #
93
+ # Memoized for performance. The memo is cleared automatically when
94
+ # `#currency_minor=` or `#currency_code=` are called.
95
+ #
96
+ # @return [Money]
97
+ def amount
98
+ @amount ||= Money.new(currency_minor, currency_code)
99
+ end
100
+
101
+ # Sets the coin's monetary value from a Money object or raw minor-unit integer.
102
+ #
103
+ # @param value [Money, Integer, Numeric] the value to assign
104
+ # - `Money` — copies `cents` and `currency.iso_code` directly.
105
+ # - `Numeric` — treated as already-scaled minor units; requires
106
+ # `#currency_code` to already be set.
107
+ # @raise [ArgumentError] if a Numeric is given without a prior currency_code
108
+ # @raise [ArgumentError] if the value type is not supported
109
+ # @return [void]
110
+ def amount=(value)
111
+ case value
112
+ when Money
113
+ self.currency_minor = value.cents
114
+ self.currency_code = value.currency.iso_code
115
+ when Numeric
116
+ raise ArgumentError, 'currency_code required before setting numeric amount' if currency_code.blank?
117
+
118
+ self.currency_minor = Integer(value)
119
+ else
120
+ raise ArgumentError, "Invalid value for Coin#amount: #{value.inspect}"
121
+ end
122
+ end
123
+
124
+ # Formats the Coin for display, optionally converting to another currency first.
125
+ #
126
+ # This is a convenience wrapper around the Money gem's `#format`. For
127
+ # richer formatting use `#present` with a pattern string.
128
+ #
129
+ # @note When `to` is given, this performs a live conversion (via
130
+ # `#convert_to`) and writes an Exchange audit row on every call. If
131
+ # formatting the same converted value repeatedly (e.g. in a view loop),
132
+ # convert once and reuse the result's `#amount.format` instead.
133
+ #
134
+ # @param to [String, nil] target ISO 4217 currency code for conversion,
135
+ # or `nil` to format in the native currency.
136
+ # @return [String] the formatted monetary string, e.g. `"$29.99"`
137
+ def format(to: nil)
138
+ return amount.format unless to
139
+
140
+ convert_to(to).amount.format
141
+ end
142
+
143
+ # @return [Integer] the raw minor-unit count (alias for `#currency_minor`)
144
+ def minor
145
+ currency_minor
146
+ end
147
+
148
+ # @return [String] the ISO currency code (alias for `#currency_code`)
149
+ def currency
150
+ currency_code
151
+ end
152
+
153
+ # @return [Integer] the raw minor-unit count (alias for `#currency_minor`)
154
+ def fractional
155
+ currency_minor
156
+ end
157
+
158
+ # Clears the memoized Money projection when the minor-unit value changes.
159
+ # @param value [Integer]
160
+ # @return [void]
161
+ def currency_minor=(value)
162
+ @amount = nil
163
+ super
164
+ end
165
+
166
+ # Clears the memoized Money projection when the currency code changes.
167
+ # @param value [String]
168
+ # @return [void]
169
+ def currency_code=(value)
170
+ @amount = nil
171
+ super
172
+ end
173
+
174
+ # Constructs a `Coin::Allocation` that interprets this Coin as a per-unit price.
175
+ #
176
+ # The Coin itself is unchanged; `Coin::Allocation` encapsulates the
177
+ # per-unit interpretation and rounding policy.
178
+ #
179
+ # @param per [Numeric] number of units this Coin covers (the divisor)
180
+ # @param rounding_policy [Symbol] one of `WhittakerTech::Midas::ROUNDING_POLICIES`
181
+ # @return [Coin::Allocation]
182
+ #
183
+ # @example Price per item from a bulk price
184
+ # bulk = Coin.value(10_000, 'USD') # $100.00 for 6 units
185
+ # alloc = bulk.allocate(per: 6, rounding_policy: :ceil)
186
+ # alloc.value # => Coin($16.67)
187
+ # alloc.price(qty: 3) # => Coin($50.00)
188
+ def allocate(per:, rounding_policy: WhittakerTech::Midas::DEFAULT_ROUNDING_POLICY)
189
+ WhittakerTech::Midas::Coin::Allocation.new(
190
+ coin: self,
191
+ divisor: per,
192
+ rounding_policy:
193
+ )
194
+ end
195
+
196
+ # Returns the number of decimal places defined by the currency specification.
197
+ #
198
+ # For example, USD = 2, JPY = 0, BHD = 3
199
+ # This is informational and does not imply rounding or formatting.
200
+ #
201
+ # @return [Integer]
202
+ def decimals
203
+ Money::Currency.new(currency_code).decimal_places
204
+ end
205
+
206
+ # Returns the scaling factor used to convert major units to minor units.
207
+ #
208
+ # Equivalent to `10 ** decimals`. For USD this is `100`; for JPY it is `1`.
209
+ #
210
+ # @return [Numeric]
211
+ def scale
212
+ 10**decimals
213
+ end
214
+
215
+ # Returns the major-unit value as a precise BigDecimal.
216
+ #
217
+ # **This is NOT payable money.** It is intended for inspection and
218
+ # formatting only. No rounding policy is applied.
219
+ #
220
+ # @return [BigDecimal]
221
+ #
222
+ # @example
223
+ # Coin.value(2999, 'USD').major # => BigDecimal("29.99")
224
+ # Coin.value(100, 'JPY').major # => BigDecimal("100")
225
+ def major
226
+ BigDecimal(currency_minor) / scale
227
+ end
228
+
229
+ class << self
230
+ # Constructs a Coin directly from an integer minor-unit count and an ISO
231
+ # currency code.
232
+ #
233
+ # This is the lowest-level factory. It enforces that `currency_minor` is
234
+ # an Integer (fractional minor units are not permitted).
235
+ #
236
+ # @param currency_minor [Integer] the amount in minor units (e.g., cents)
237
+ # @param currency_code [String] ISO 4217 currency code, e.g. `"USD"`
238
+ # @return [Coin]
239
+ # @raise [TypeError] if `currency_minor` is not an Integer
240
+ #
241
+ # @example
242
+ # Coin.value(2999, 'USD') # => $29.99
243
+ # Coin.value(0, 'JPY') # => ¥0
244
+ def value(currency_minor, currency_code)
245
+ raise TypeError unless currency_minor.is_a?(Integer)
246
+
247
+ new(currency_minor:, currency_code:)
248
+ end
249
+
250
+ # Returns a zero-valued Coin in the given currency.
251
+ #
252
+ # @param currency_code [String] ISO 4217 currency code
253
+ # @return [Coin]
254
+ #
255
+ # @example
256
+ # Coin.zero('USD') # => $0.00
257
+ def zero(currency_code)
258
+ new(currency_minor: 0, currency_code:)
79
259
  end
260
+
261
+ # Parses a heterogeneous input into a Coin using `Coin::Parser`.
262
+ #
263
+ # Accepted input types: `Coin`, `Money`, `Numeric`, `String`.
264
+ #
265
+ # @param value [Coin, Money, Numeric, String] the value to parse
266
+ # @param currency_code [String, nil] required for Numeric and bare String inputs
267
+ # @return [Coin]
268
+ # @raise [TypeError] if the input type cannot be converted
269
+ # @raise [ArgumentError] if a currency_code is required but not provided
270
+ #
271
+ # @example
272
+ # Coin.parse(Money.new(2999, 'USD'))
273
+ # Coin.parse(29.99, currency_code: 'USD')
274
+ # Coin.parse('$29.99')
275
+ def parse(value, currency_code: nil)
276
+ Parser.parse(value, currency_code:)
277
+ end
278
+ end
279
+
280
+ private
281
+
282
+ # Normalizes currency_code before validation.
283
+ #
284
+ # - `currency_code` is stripped and capitalized
285
+ # - `resource_role` normalisation is handled by `Poly::Role`
286
+ # @return [void]
287
+ def normalize_currency_code
288
+ self.currency_code = currency_code.to_s.strip.upcase.presence if currency_code
80
289
  end
81
290
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Exchange is an immutable audit record of a single currency conversion.
4
+ #
5
+ # It owns two Coins via the standard Bankable DSL — `from` (a copy of the
6
+ # value being converted) and `to` (the converted result) — plus the rate,
7
+ # provider, and timestamp used.
8
+ #
9
+ # Exchange is write-only: nothing reads it back to resolve future
10
+ # conversions. It exists purely as an auditable record of what was
11
+ # converted, at what rate, using which provider, and when.
12
+ #
13
+ # @since 0.3.0
14
+ class WhittakerTech::Midas::Exchange < WhittakerTech::Midas::ApplicationRecord
15
+ include WhittakerTech::Midas::Bankable
16
+
17
+ self.table_name = WhittakerTech::Midas.table_name('exchanges')
18
+
19
+ # Gives #from/#to (Coin readers), #from_amount/#to_amount,
20
+ # #from_format/#to_format, and #set_from/#set_to.
21
+ has_coins :from, :to
22
+
23
+ validates :rate, presence: true
24
+ validates :source, presence: true
25
+ validates :at, presence: true
26
+
27
+ before_update :block_updates
28
+
29
+ private
30
+
31
+ # Exchange rows are set once at creation and never touched again — the
32
+ # coins it owns attach separately via Bankable and don't trigger this
33
+ # callback. Does not block #destroy.
34
+ def block_updates
35
+ raise ActiveRecord::ReadOnlyRecord, 'WhittakerTech::Midas::Exchange records are immutable after creation'
36
+ end
37
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'whittaker_tech/midas'
4
+
5
+ class CreateWhittakerTechSchema < ActiveRecord::Migration[8.0]
6
+ SCHEMA_NAME = (WhittakerTech::Midas.table_namespace || 'midas').freeze
7
+ raise 'Invalid schema name' unless SCHEMA_NAME =~ /\A[a-z_][a-z0-9_]*\z/
8
+
9
+ def up
10
+ return unless postgresql?
11
+
12
+ execute "CREATE SCHEMA IF NOT EXISTS #{SCHEMA_NAME}"
13
+ end
14
+
15
+ def down
16
+ return unless postgresql?
17
+
18
+ execute "DROP SCHEMA #{SCHEMA_NAME} CASCADE"
19
+ end
20
+
21
+ private
22
+
23
+ def postgresql?
24
+ connection.adapter_name == 'PostgreSQL'
25
+ end
26
+ end
@@ -1,6 +1,10 @@
1
- class CreateWtMidasCoins < ActiveRecord::Migration[8.0]
1
+ # frozen_string_literal: true
2
+
3
+ require 'whittaker_tech/midas'
4
+
5
+ class CreateMidasCoins < ActiveRecord::Migration[8.0]
2
6
  def change
3
- create_table :wt_midas_coins do |t|
7
+ create_table WhittakerTech::Midas.table_name('coins') do |t|
4
8
  t.references :resource, polymorphic: true, null: false, index: true
5
9
  t.string :resource_label, null: false, limit: 64
6
10
  t.string :currency_code, null: false, limit: 3
@@ -10,7 +14,7 @@ class CreateWtMidasCoins < ActiveRecord::Migration[8.0]
10
14
 
11
15
  t.index %i[resource_id resource_type resource_label],
12
16
  unique: true,
13
- name: 'index_wt_midas_coins_on_owner_and_label'
17
+ name: 'index_midas_coins_on_owner_and_label'
14
18
  end
15
19
  end
16
20
  end