whittaker_tech-midas 0.2.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79ce2a5c7e59f15a9ca7af2ea759c514a32fcdc67915618d01d4f1cf2c00bd9a
4
- data.tar.gz: 149f3fa609b856dffdef559835f668bb47758c00262d5cb2d4517ef21f3c1892
3
+ metadata.gz: a3fd5f9cd2e7d3ffac65eaedd6353fca8f73ad4e786207f8402791b485454ff0
4
+ data.tar.gz: 30445ce5ed3a5ebf775f56c8a05afaf9eb401bac0f0a84a624cc9d4ddafbe9b8
5
5
  SHA512:
6
- metadata.gz: f446717fbf1acd4e4a3648eec3aeadbaa71e6263b60e77b68e3c960cfaa10a958783888d5bfa0df53c39cbff8e4849193a173aaa423fd4e92830bd7a954c22a5
7
- data.tar.gz: 538185c60138515129e23ad762985a2bed02ebb369c6c02311107a06c876f481a66df71c4d4437ff99d39d43d67974e18f81b4c172c4d50dde16945dff0b1d34
6
+ metadata.gz: 82e65cff44ba88558f2ee2563f107c88399ad41d65e008ae23b8d89846982c292be06c5dfbe3518810b11a1986359156b29119831614ac93d87d2ba5d28c99ef
7
+ data.tar.gz: b14740dd0c7ec5fc0d195fabf5bb81da564e456693a4cef8bca03f042a5964a03a7754776fa24cf9c2dca0b6bfb551ec075ef4da841cdfe674e1c50c9cb4b1c5
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # WhittakerTech::Midas
2
2
 
3
3
  [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](MIT-LICENSE)
4
- ![Ruby 3.4](https://img.shields.io/badge/ruby-3.4+-red.svg)
4
+ ![Ruby 3.2](https://img.shields.io/badge/ruby-3.2+-red.svg)
5
5
  ![Rails 7.1](https://img.shields.io/badge/rails-7.1+-crimson.svg)
6
6
  [![Gem Version](https://badge.fury.io/rb/whittaker_tech-midas.svg)](https://badge.fury.io/rb/whittaker_tech-midas)
7
7
  [![CI](https://github.com/WhittakerTech/midas/actions/workflows/ci.yml/badge.svg)](https://github.com/WhittakerTech/midas/actions)
@@ -77,7 +77,7 @@ product.set_price(amount: 2999, currency_code: 'USD')
77
77
  product.price # => Coin object
78
78
  product.price_amount # => Money object (#<Money @cents=2999 @currency="USD">)
79
79
  product.price_format # => "$29.99"
80
- product.price_in('EUR') # => "€26.85" (if exchange rates configured)
80
+ product.price_in('EUR') # => "€26.85"
81
81
  ```
82
82
 
83
83
  ## Usage Guide
@@ -196,7 +196,7 @@ Money.default_formatting_rules = {
196
196
 
197
197
  ### Exchange Rates
198
198
 
199
- Set up exchange rates for currency conversion:
199
+ Set up exchange rates on `Money.default_bank` as usual:
200
200
  ```ruby
201
201
  # In your app
202
202
  Money.default_bank.add_rate('USD', 'EUR', 0.85)
@@ -211,6 +211,68 @@ For production, integrate with an exchange rate API:
211
211
  - [money-open-exchange-rates](https://github.com/spk/money-open-exchange-rates)
212
212
  - [google_currency](https://github.com/RubyMoney/google_currency)
213
213
 
214
+ #### How conversion works
215
+
216
+ `Coin` conversion is provider-agnostic. By default it wraps whatever bank is
217
+ set on `Money.default_bank` (via `Coin::Converter::BankProvider`), so any of
218
+ the gems above work unmodified. You can also convert directly:
219
+
220
+ ```ruby
221
+ coin = product.price
222
+ coin.convert_to('EUR') # => new, persisted Coin in EUR
223
+ coin.exchange_to('EUR') # => alias for convert_to
224
+ ```
225
+
226
+ Every conversion — whether via `convert_to`, `exchange_to`, `#{name}_in`, or
227
+ `Coin#format(to:)` — writes an immutable `WhittakerTech::Midas::Exchange`
228
+ audit row recording the `from`/`to` coins, the `rate` used, the provider
229
+ `source`, and the timestamp (`at`). This is a write-only audit log, not a
230
+ rate cache: `convert_to` never reads past `Exchange` rows back to resolve a
231
+ rate, it always asks the provider fresh.
232
+
233
+ ```ruby
234
+ result = coin.convert_to('EUR')
235
+ exchange = WhittakerTech::Midas::Exchange.last
236
+ exchange.from # => Coin copy of the original value (USD)
237
+ exchange.to # => the converted result (== `result`)
238
+ exchange.rate # => BigDecimal rate used
239
+ exchange.source # => "money:Money::Bank::VariableExchange"
240
+ exchange.at # => Time the conversion was made
241
+ ```
242
+
243
+ **`format(to:)` converts on every call.** If you need the same converted
244
+ value multiple times, convert once and reuse the result instead of calling
245
+ `format(to:)` repeatedly — each call performs a live conversion and writes a
246
+ new `Exchange` row:
247
+
248
+ ```ruby
249
+ # Avoid — converts and audits twice
250
+ coin.format(to: 'EUR')
251
+ coin.format(to: 'EUR')
252
+
253
+ # Prefer — convert once, format many times
254
+ converted = coin.convert_to('EUR')
255
+ converted.amount.format
256
+ ```
257
+
258
+ **Historical rates.** Passing `at:` requires a provider that implements
259
+ `#exchange_at(money, currency_code, at:)` — the default `BankProvider` does
260
+ not, since `Money::Bank::VariableExchange` has no historical capability.
261
+ Passing `at:` against the default provider raises `ArgumentError`. Supply a
262
+ custom provider via `using:` for historical support:
263
+
264
+ ```ruby
265
+ coin.convert_to('EUR', at: 3.months.ago, using: my_historical_provider)
266
+ ```
267
+
268
+ **Custom providers.** Any object responding to `#exchange(money, currency_code)`
269
+ and `#name` can be passed as `using:` to override the default bank-backed
270
+ provider — useful for testing or wiring in a rate API directly:
271
+
272
+ ```ruby
273
+ coin.convert_to('EUR', using: my_provider)
274
+ ```
275
+
214
276
  ## Advanced Usage
215
277
 
216
278
  ### Multiple Coins on One Resource
@@ -230,7 +292,7 @@ order.set_shipping(amount: 850, currency_code: 'EUR')
230
292
 
231
293
  order.subtotal_format # => "$100.00"
232
294
  order.shipping_format # => "€8.50"
233
- order.shipping_in('USD') # => "$10.00" (with exchange rate)
295
+ order.shipping_in('USD') # => "$10.00"
234
296
  ```
235
297
 
236
298
  ### Working with Coin Objects Directly
@@ -366,11 +428,18 @@ bin/rails server
366
428
 
367
429
  ### Exchange rates not working
368
430
 
369
- Make sure you've configured exchange rates:
431
+ Make sure you've configured exchange rates on `Money.default_bank` (the
432
+ default provider raises whatever error the underlying bank raises, e.g.
433
+ `Money::Bank::UnknownRate`, if a rate is missing):
370
434
  ```ruby
371
435
  Money.default_bank.add_rate('USD', 'EUR', 0.85)
372
436
  ```
373
437
 
438
+ ### `ArgumentError` mentioning "historical" from `convert_to`
439
+
440
+ You passed `at:` without a provider that supports it. Either omit `at:` or
441
+ supply `using:` with a provider implementing `#exchange_at`.
442
+
374
443
  ### Input field not formatting
375
444
 
376
445
  Check that Stimulus is loaded and the controller is registered:
@@ -52,6 +52,8 @@
52
52
  # - `name`: Returns the associated Coin object
53
53
  # - `name_amount`: Returns the Money object representing the amount
54
54
  # - `name_format`: Returns a formatted string representation of the amount
55
+ # - `name_in(currency_code)`: Converts and formats the coin in another currency
56
+ # (writes an Exchange audit row on every call — see `Coin::Converter`)
55
57
  # - `set_name(amount:, currency_code:)`: Sets the coin value with the given amount and currency
56
58
  #
57
59
  # == Supported Amount Types
@@ -129,7 +131,7 @@ module WhittakerTech::Midas::Bankable
129
131
 
130
132
  define_method("#{name}_amount") { public_send(name)&.amount }
131
133
  define_method("#{name}_format") { public_send(name)&.amount&.format }
132
- # define_method("#{name}_in") { |to| public_send(name)&.exchange_to(to)&.format }
134
+ define_method("#{name}_in") { |to| public_send(name)&.exchange_to(to)&.format }
133
135
 
134
136
  # Sets the coin value with the specified amount and currency.
135
137
  #
@@ -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
@@ -1,32 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Converter is a reserved module for future currency conversion logic.
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`).
4
8
  #
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
9
+ # @since 0.3.0
21
10
  module WhittakerTech::Midas::Coin::Converter
22
11
  # Converts this Coin to another currency.
23
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
+ #
24
18
  # @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
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
31
78
  end
32
79
  end
@@ -27,7 +27,7 @@
27
27
  # +------------+----------------------------------------------+
28
28
  # | Arithmetic | +, -, *, /, %, negate, equality |
29
29
  # | Bidi | Unicode bidirectional text isolation |
30
- # | Converter | Currency conversion (reserved, not yet live) |
30
+ # | Converter | Currency conversion (live, audited via Exchange) |
31
31
  # | Presenter | Token-based formatting grammar |
32
32
  # +------------+----------------------------------------------+
33
33
  #
@@ -46,13 +46,12 @@
46
46
  # - `WhittakerTech::Midas::Coin::Arithmetic`
47
47
  # - `WhittakerTech::Midas::Coin::Allocation`
48
48
  # @since 0.1.0
49
- # rubocop:disable Metrics/ClassLength
50
49
  class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
51
50
  # Arithmetic: exact arithmetic and equality semantics
52
51
  include Arithmetic
53
52
  # Bidi: bidirectional currency conversion
54
53
  include Bidi
55
- # Converter: future currency conversion logic
54
+ # Converter: live currency conversion + Exchange audit trail
56
55
  include Converter
57
56
  # Presenter: formatting and presentation logic
58
57
  include Presenter
@@ -127,16 +126,18 @@ class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
127
126
  # This is a convenience wrapper around the Money gem's `#format`. For
128
127
  # richer formatting use `#present` with a pattern string.
129
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
+ #
130
134
  # @param to [String, nil] target ISO 4217 currency code for conversion,
131
135
  # or `nil` to format in the native currency.
132
136
  # @return [String] the formatted monetary string, e.g. `"$29.99"`
133
137
  def format(to: nil)
134
- if to
135
- raise NotImplementedError,
136
- 'Currency conversion is not yet implemented. Use #amount.format for native formatting.'
137
- end
138
+ return amount.format unless to
138
139
 
139
- amount.format
140
+ convert_to(to).amount.format
140
141
  end
141
142
 
142
143
  # @return [Integer] the raw minor-unit count (alias for `#currency_minor`)
@@ -276,35 +277,6 @@ class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
276
277
  end
277
278
  end
278
279
 
279
- # ── Deprecated ────────────────────────────────────────────────────────── #
280
-
281
- # @deprecated Use `#resource_role` instead. Will be removed in v0.3.0.
282
- def resource_label
283
- WhittakerTech::Midas::Deprecation.warn(
284
- 'Coin#resource_label is deprecated. Use Coin#resource_role instead.',
285
- caller_locations(1, 1)&.first
286
- )
287
- resource_role
288
- end
289
-
290
- # @deprecated Use `#resource_role=` instead. Will be removed in v0.3.0.
291
- def resource_label=(value)
292
- WhittakerTech::Midas::Deprecation.warn(
293
- 'Coin#resource_label= is deprecated. Use Coin#resource_role= instead.',
294
- caller_locations(1, 1)&.first
295
- )
296
- self.resource_role = value
297
- end
298
-
299
- # @deprecated Use `for_role` instead. Will be removed in v0.3.0.
300
- def self.for_label(label)
301
- WhittakerTech::Midas::Deprecation.warn(
302
- 'Coin.for_label is deprecated. Use Coin.for_role instead.',
303
- caller_locations(1, 1)&.first
304
- )
305
- for_role(label)
306
- end
307
-
308
280
  private
309
281
 
310
282
  # Normalizes currency_code before validation.
@@ -316,4 +288,3 @@ class WhittakerTech::Midas::Coin < WhittakerTech::Midas::ApplicationRecord
316
288
  self.currency_code = currency_code.to_s.strip.upcase.presence if currency_code
317
289
  end
318
290
  end
319
- # rubocop:enable Metrics/ClassLength
@@ -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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'whittaker_tech/midas'
4
+
5
+ class CreateMidasExchanges < ActiveRecord::Migration[8.0]
6
+ def change
7
+ create_table WhittakerTech::Midas.table_name('exchanges') do |t|
8
+ t.decimal :rate, precision: 24, scale: 12, null: false
9
+ t.string :source, null: false
10
+ t.datetime :at, null: false
11
+
12
+ t.timestamps
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  module WhittakerTech; end
2
2
 
3
3
  module WhittakerTech::Midas
4
- VERSION = '0.2.0'.freeze
4
+ VERSION = '0.3.0'.freeze
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whittaker_tech-midas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lee Whittaker
@@ -220,8 +220,10 @@ files:
220
220
  - app/models/whittaker_tech/midas/coin/arithmetic.rb
221
221
  - app/models/whittaker_tech/midas/coin/bidi.rb
222
222
  - app/models/whittaker_tech/midas/coin/converter.rb
223
+ - app/models/whittaker_tech/midas/coin/converter/bank_provider.rb
223
224
  - app/models/whittaker_tech/midas/coin/parser.rb
224
225
  - app/models/whittaker_tech/midas/coin/presenter.rb
226
+ - app/models/whittaker_tech/midas/exchange.rb
225
227
  - app/views/layouts/whittaker_tech/midas/application.html.erb
226
228
  - app/views/layouts/whittaker_tech/midas/shared/_currency_field.html.erb
227
229
  - config/locales/midas.en.yml
@@ -229,6 +231,7 @@ files:
229
231
  - db/migrate/20260101000001_create_midas_coins.rb
230
232
  - db/migrate/20260219120000_rename_resource_label_to_resource_role_in_wt_midas_coins.rb
231
233
  - db/migrate/20260219150000_rename_wt_midas_coins_to_midas_coins.rb
234
+ - db/migrate/20260707000000_create_midas_exchanges.rb
232
235
  - lib/generators/whittaker_tech/midas/install/install_generator.rb
233
236
  - lib/tasks/whittaker_tech/midas_tasks.rake
234
237
  - lib/whittaker_tech/midas.rb