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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 027c1179177e4e7a6c618a55e196fad98d2b617a91b7ef70a49fb8653bb983ae
4
- data.tar.gz: 54a5943974b9b93be6dfed049b87566afd9c952c560f5b286e62dba46ee18d1c
3
+ metadata.gz: a3fd5f9cd2e7d3ffac65eaedd6353fca8f73ad4e786207f8402791b485454ff0
4
+ data.tar.gz: 30445ce5ed3a5ebf775f56c8a05afaf9eb401bac0f0a84a624cc9d4ddafbe9b8
5
5
  SHA512:
6
- metadata.gz: e6bde8babeb983a210ebb849c01a407c620491be821260782b43977747d84b4c07b23e3878597d49e7e0a2421dd3aab10ffc5aa165403f35f56b152ecdcc8e4f
7
- data.tar.gz: dec832c4c97ae6155a9b67ed0dade3254ea06509446855ace527b6d574018bcafa162ad9d0a5849bf68636365ef38e5627bd16639acae290c6eb58f3890f6eed
6
+ metadata.gz: 82e65cff44ba88558f2ee2563f107c88399ad41d65e008ae23b8d89846982c292be06c5dfbe3518810b11a1986359156b29119831614ac93d87d2ba5d28c99ef
7
+ data.tar.gz: b14740dd0c7ec5fc0d195fabf5bb81da564e456693a4cef8bca03f042a5964a03a7754776fa24cf9c2dca0b6bfb551ec075ef4da841cdfe674e1c50c9cb4b1c5
data/README.md CHANGED
@@ -1,8 +1,8 @@
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)]()
5
- [![Rails 7.1](https://img.shields.io/badge/rails-7.1+-crimson.svg)]()
4
+ ![Ruby 3.2](https://img.shields.io/badge/ruby-3.2+-red.svg)
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)
8
8
 
@@ -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:
@@ -1,6 +1,2 @@
1
- module WhittakerTech
2
- module Midas
3
- class ApplicationController < ActionController::Base
4
- end
5
- end
1
+ class WhittakerTech::Midas::ApplicationController < ActionController::Base
6
2
  end
@@ -1,6 +1,2 @@
1
- module WhittakerTech
2
- module Midas
3
- module ApplicationHelper
4
- end
5
- end
1
+ module WhittakerTech::Midas::ApplicationHelper
6
2
  end
@@ -1,78 +1,74 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module WhittakerTech
4
- module Midas
5
- # Form helper for rendering currency input fields with bank-style typing behavior.
6
- #
7
- # This helper provides a headless currency input that the parent application
8
- # can style according to its design system. The helper only provides the
9
- # behavior (via Stimulus) and basic structure.
10
- #
11
- # @example Basic usage (unstyled)
12
- # <%= midas_currency_field f, :price, currency_code: 'USD' %>
13
- #
14
- # @example With Tailwind styling
15
- # <%= midas_currency_field f, :price,
16
- # currency_code: 'USD',
17
- # input_html: { class: 'rounded-lg border-gray-300 text-right' },
18
- # wrapper_html: { class: 'mb-4' },
19
- # label: 'Product Price' %>
20
- #
21
- # @example With Bootstrap styling
22
- # <%= midas_currency_field f, :price,
23
- # currency_code: 'EUR',
24
- # input_html: { class: 'form-control text-end' },
25
- # wrapper_html: { class: 'mb-3' },
26
- # label: 'Price (€)' %>
27
- module FormHelper
28
- # Renders a currency input field with bank-style typing behavior.
29
- #
30
- # The field consists of:
31
- # - A visible formatted input (displays dollars/euros)
32
- # - A hidden input storing minor units (cents)
33
- # - A hidden input storing the currency code
34
- #
35
- # @param form [ActionView::Helpers::FormBuilder] The form builder
36
- # @param attribute [Symbol] The coin attribute name (e.g., :price, :cost)
37
- # @param currency_code [String] ISO 4217 currency code (e.g., 'USD', 'EUR')
38
- # @param options [Hash] Additional options
39
- # @option options [Hash] :input_html HTML attributes for the display input
40
- # @option options [Hash] :wrapper_html HTML attributes for the wrapper div
41
- # @option options [Integer] :decimals Number of decimal places (default: 2)
42
- # @option options [String] :label Label text (optional, no label if nil)
43
- #
44
- # @return [String] Rendered HTML for the currency field
45
- #
46
- # @example
47
- # <%= midas_currency_field f, :price,
48
- # currency_code: 'USD',
49
- # decimals: 2,
50
- # label: 'Sale Price',
51
- # wrapper_html: { class: 'form-group' },
52
- # input_html: { class: 'form-control', placeholder: '0.00' } %>
53
- def midas_currency_field(form, attribute, currency_code:, **options)
54
- input_html = options.fetch(:input_html, {})
55
- wrapper_html = options.fetch(:wrapper_html, {})
56
- decimals = options.fetch(:decimals, 2)
57
- label_text = options.fetch(:label, nil)
3
+ # Form helper for rendering currency input fields with bank-style typing behavior.
4
+ #
5
+ # This helper provides a headless currency input that the parent application
6
+ # can style according to its design system. The helper only provides the
7
+ # behavior (via Stimulus) and basic structure.
8
+ #
9
+ # @example Basic usage (unstyled)
10
+ # <%= midas_currency_field f, :price, currency_code: 'USD' %>
11
+ #
12
+ # @example With Tailwind styling
13
+ # <%= midas_currency_field f, :price,
14
+ # currency_code: 'USD',
15
+ # input_html: { class: 'rounded-lg border-gray-300 text-right' },
16
+ # wrapper_html: { class: 'mb-4' },
17
+ # label: 'Product Price' %>
18
+ #
19
+ # @example With Bootstrap styling
20
+ # <%= midas_currency_field f, :price,
21
+ # currency_code: 'EUR',
22
+ # input_html: { class: 'form-control text-end' },
23
+ # wrapper_html: { class: 'mb-3' },
24
+ # label: 'Price (€)' %>
25
+ module WhittakerTech::Midas::FormHelper
26
+ # Renders a currency input field with bank-style typing behavior.
27
+ #
28
+ # The field consists of:
29
+ # - A visible formatted input (displays dollars/euros)
30
+ # - A hidden input storing minor units (cents)
31
+ # - A hidden input storing the currency code
32
+ #
33
+ # @param form [ActionView::Helpers::FormBuilder] The form builder
34
+ # @param attribute [Symbol] The coin attribute name (e.g., :price, :cost)
35
+ # @param currency_code [String] ISO 4217 currency code (e.g., 'USD', 'EUR')
36
+ # @param options [Hash] Additional options
37
+ # @option options [Hash] :input_html HTML attributes for the display input
38
+ # @option options [Hash] :wrapper_html HTML attributes for the wrapper div
39
+ # @option options [Integer] :decimals Number of decimal places (default: 2)
40
+ # @option options [String] :label Label text (optional, no label if nil)
41
+ #
42
+ # @return [String] Rendered HTML for the currency field
43
+ #
44
+ # @example
45
+ # <%= midas_currency_field f, :price,
46
+ # currency_code: 'USD',
47
+ # decimals: 2,
48
+ # label: 'Sale Price',
49
+ # wrapper_html: { class: 'form-group' },
50
+ # input_html: { class: 'form-control', placeholder: '0.00' } %>
51
+ def midas_currency_field(form, attribute, currency_code:, **options)
52
+ input_html = options.fetch(:input_html, {})
53
+ wrapper_html = options.fetch(:wrapper_html, {})
54
+ decimals = options.fetch(:decimals, 2)
55
+ label_text = options.fetch(:label, nil)
58
56
 
59
- # Get current value if coin exists
60
- resource = form.object
61
- coin = resource.public_send(attribute) if resource.respond_to?(attribute)
62
- current_minor = coin&.currency_minor || 0
57
+ # Get current value if coin exists
58
+ resource = form.object
59
+ coin = resource.public_send(attribute) if resource.respond_to?(attribute)
60
+ current_minor = coin&.currency_minor || 0
63
61
 
64
- render partial: 'whittaker_tech/midas/shared/currency_field',
65
- locals: {
66
- form: form,
67
- attribute: attribute,
68
- currency_code: currency_code,
69
- current_minor: current_minor,
70
- decimals: decimals,
71
- input_html: input_html,
72
- wrapper_html: wrapper_html,
73
- label_text: label_text
74
- }
75
- end
76
- end
62
+ render partial: 'whittaker_tech/midas/shared/currency_field',
63
+ locals: {
64
+ form: form,
65
+ attribute: attribute,
66
+ currency_code: currency_code,
67
+ current_minor: current_minor,
68
+ decimals: decimals,
69
+ input_html: input_html,
70
+ wrapper_html: wrapper_html,
71
+ label_text: label_text
72
+ }
77
73
  end
78
74
  end
@@ -1,6 +1,2 @@
1
- module WhittakerTech
2
- module Midas
3
- class ApplicationJob < ActiveJob::Base
4
- end
5
- end
1
+ class WhittakerTech::Midas::ApplicationJob < ActiveJob::Base
6
2
  end
@@ -1,8 +1,4 @@
1
- module WhittakerTech
2
- module Midas
3
- class ApplicationMailer < ActionMailer::Base
4
- default from: 'from@example.com'
5
- layout 'mailer'
6
- end
7
- end
1
+ class WhittakerTech::Midas::ApplicationMailer < ActionMailer::Base
2
+ default from: 'from@example.com'
3
+ layout 'mailer'
8
4
  end