whittaker_tech-midas 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1a33ba6f551628e1fb4d01b0c1160da9220eec07cca5926476b9157fad1b383b
4
+ data.tar.gz: 28e9fb2c7edf02c4063dcd7bc8ddc2e1bbb23584d52e49767fa200299eadf559
5
+ SHA512:
6
+ metadata.gz: c1996ca7558cedaf6698ef888706c37ff3093aee0044a113869fd7fad73e0e85a8d411772ffb470386262701637be761dd33dcc8cdb801bbda9ad3521254c55d
7
+ data.tar.gz: 4e24a783417364ad0b2f51608847f89b4124b57257e262d36e7300d6b123b2d92d6bcdb23cdc64721b943021f4ebb7c9dcce0a0137ef3630dd5268547083f8de
data/README.md ADDED
@@ -0,0 +1,418 @@
1
+ # WhittakerTech::Midas
2
+
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)]()
6
+ [![Gem Version](https://badge.fury.io/rb/whittaker_tech-midas.svg)](https://badge.fury.io/rb/whittaker_tech-midas)
7
+ [![CI](https://github.com/WhittakerTech/midas/actions/workflows/ci.yml/badge.svg)](https://github.com/WhittakerTech/midas/actions)
8
+
9
+ A Rails engine for elegant monetary value management with multi-currency support. Midas provides a single source of truth for all currency values in your application, eliminating schema bloat and unifying currency behavior.
10
+
11
+ ## Why I Made Midas
12
+ Midas was created because monetization code becomes one of the most fragile parts of a Rails application.
13
+ Teams duplicate currency logic across dozens of models, leading to rounding inconsistencies, schema bloat, and costly refactors during growth phases.
14
+ Midas centralizes all monetary behavior into a single, predictable source of truth.
15
+ This design keeps your pricing, billing, and financial reporting consistent across the entire system.
16
+
17
+ ## Key Capabilities
18
+
19
+ - Single canonical `Coin` model as a unified monetary ledger
20
+ - Declarative monetary attributes via `has_coin` and `has_coins`
21
+ - Money-safe arithmetic backed by RubyMoney’s precision library
22
+ - Automatic minor-unit conversion for all input types (int, float, Money)
23
+ - Multi-currency support with configurable exchange rates
24
+ - Headless currency input UI for form builders
25
+ - Test suite with >90% coverage
26
+ - Zero schema duplication—no proliferation of `_cents` columns
27
+
28
+ ## Requirements
29
+
30
+ - Ruby 3.4+
31
+ - Rails 7.1+
32
+ - money gem ~> 6.19.0
33
+
34
+ ## Installation
35
+
36
+ Add to your `Gemfile`:
37
+ ```ruby
38
+ gem 'whittaker_tech-midas', path: 'engines/whittaker_tech-midas'
39
+ ```
40
+
41
+ Install and run migrations:
42
+ ```bash
43
+ bundle install
44
+ bin/rails railties:install:migrations FROM=whittaker_tech_midas
45
+ bin/rails db:migrate
46
+ ```
47
+
48
+ This creates the `wt_midas_coins` table.
49
+
50
+ ## Quick Start
51
+
52
+ ### 1. Include Bankable in Your Model
53
+ ```ruby
54
+ class Product < ApplicationRecord
55
+ include WhittakerTech::Midas::Bankable
56
+
57
+ has_coins :price, :cost, :msrp
58
+ end
59
+ ```
60
+
61
+ ### 2. Set Monetary Values
62
+ ```ruby
63
+ product = Product.create!
64
+
65
+ # From float (dollars)
66
+ product.set_price(amount: 29.99, currency_code: 'USD')
67
+
68
+ # From Money object
69
+ product.set_price(amount: Money.new(2999, 'USD'), currency_code: 'USD')
70
+
71
+ # From integer (cents)
72
+ product.set_price(amount: 2999, currency_code: 'USD')
73
+ ```
74
+
75
+ ### 3. Access Values
76
+ ```ruby
77
+ product.price # => Coin object
78
+ product.price_amount # => Money object (#<Money @cents=2999 @currency="USD">)
79
+ product.price_format # => "$29.99"
80
+ product.price_in('EUR') # => "€26.85" (if exchange rates configured)
81
+ ```
82
+
83
+ ## Usage Guide
84
+
85
+ ### The Coin Model
86
+
87
+ Every monetary value is stored as a `Coin` with:
88
+ - `resource_type` / `resource_id`: Polymorphic association to parent
89
+ - `resource_label`: Identifies which money attribute (e.g., "price")
90
+ - `currency_code`: ISO 4217 code (USD, EUR, JPY, etc.)
91
+ - `currency_minor`: Integer value in minor units (cents, pence)
92
+
93
+ ### The Bankable Concern
94
+
95
+ Include `Bankable` to add monetary attributes to any model:
96
+ ```ruby
97
+ class Invoice < ApplicationRecord
98
+ include WhittakerTech::Midas::Bankable
99
+
100
+ has_coins :subtotal, :tax, :total
101
+ end
102
+ ```
103
+
104
+ #### Single Coin
105
+ ```ruby
106
+ has_coin :price
107
+ has_coin :deposit, dependent: :nullify # Custom dependency
108
+ ```
109
+
110
+ #### Multiple Coins
111
+ ```ruby
112
+ has_coins :subtotal, :tax, :shipping, :total
113
+ ```
114
+
115
+ ### Generated Methods
116
+
117
+ For each `has_coin :price`, you get:
118
+
119
+ | Method | Returns | Example |
120
+ |--------|---------|---------|
121
+ | `price` | Coin object | `product.price` |
122
+ | `price_coin` | Coin association | `product.price_coin` |
123
+ | `price_amount` | Money object | `Money<2999 USD>` |
124
+ | `price_format` | Formatted string | `"$29.99"` |
125
+ | `price_in(currency)` | Formatted conversion | `"€26.85"` |
126
+ | `set_price(amount:, currency_code:)` | Creates/updates coin | Returns Coin |
127
+ | `midas_coins` | All coins on resource | `product.midas_coins.count` |
128
+
129
+ ### Currency Input Field (UI)
130
+
131
+ Midas provides a headless Stimulus-powered currency input with bank-style typing:
132
+ ```erb
133
+ <%= form_with model: @product do |f| %>
134
+ <%= midas_currency_field f, :price,
135
+ currency_code: 'USD',
136
+ label: 'Product Price',
137
+ wrapper_html: { class: 'mb-4' },
138
+ input_html: {
139
+ class: 'rounded-lg border-gray-300 text-right',
140
+ placeholder: '0.00'
141
+ } %>
142
+
143
+ <%= f.submit %>
144
+ <% end %>
145
+ ```
146
+
147
+ **Bank-Style Typing:**
148
+ User types `1234` → displays as `0.01` → `0.12` → `1.23` → `12.34`
149
+
150
+ **Features:**
151
+ - Automatic decimal handling based on currency
152
+ - Hidden field stores minor units (cents)
153
+ - Style with Tailwind, Bootstrap, or custom CSS
154
+ - Backspace removes rightmost digit
155
+
156
+ ### Currency Configuration
157
+
158
+ Define currency-specific settings via I18n:
159
+ ```yaml
160
+ # config/locales/midas.en.yml
161
+ en:
162
+ midas:
163
+ ui:
164
+ defaults:
165
+ decimal_count: 2
166
+ currencies:
167
+ USD:
168
+ decimal_count: 2
169
+ symbol: "$"
170
+ JPY:
171
+ decimal_count: 0
172
+ symbol: "¥"
173
+ BTC:
174
+ decimal_count: 8
175
+ symbol: "₿"
176
+ ```
177
+
178
+ ### Money Gem Configuration
179
+
180
+ Configure Money gem behavior (recommended):
181
+ ```ruby
182
+ # config/initializers/money.rb
183
+ Money.locale_backend = nil # or :i18n for i18n support
184
+ Money.default_bank = Money::Bank::VariableExchange.new
185
+ Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN
186
+
187
+ Money.default_formatting_rules = {
188
+ display_free: false,
189
+ with_currency: false,
190
+ no_cents_if_whole: false,
191
+ format: '%u%n', # symbol before amount
192
+ thousands_separator: ',',
193
+ decimal_mark: '.'
194
+ }
195
+ ```
196
+
197
+ ### Exchange Rates
198
+
199
+ Set up exchange rates for currency conversion:
200
+ ```ruby
201
+ # In your app
202
+ Money.default_bank.add_rate('USD', 'EUR', 0.85)
203
+ Money.default_bank.add_rate('EUR', 'USD', 1.18)
204
+
205
+ # Now conversions work
206
+ product.price_in('EUR') # Automatic conversion
207
+ ```
208
+
209
+ For production, integrate with an exchange rate API:
210
+ - [eu_central_bank](https://github.com/RubyMoney/eu_central_bank)
211
+ - [money-open-exchange-rates](https://github.com/spk/money-open-exchange-rates)
212
+ - [google_currency](https://github.com/RubyMoney/google_currency)
213
+
214
+ ## Advanced Usage
215
+
216
+ ### Multiple Coins on One Resource
217
+ ```ruby
218
+ order = Order.create!
219
+ order.set_subtotal(amount: 100.00, currency_code: 'USD')
220
+ order.set_tax(amount: 8.50, currency_code: 'USD')
221
+ order.set_total(amount: 108.50, currency_code: 'USD')
222
+
223
+ order.midas_coins.count # => 3
224
+ ```
225
+
226
+ ### Mixed Currencies
227
+ ```ruby
228
+ order.set_subtotal(amount: 100, currency_code: 'USD')
229
+ order.set_shipping(amount: 850, currency_code: 'EUR')
230
+
231
+ order.subtotal_format # => "$100.00"
232
+ order.shipping_format # => "€8.50"
233
+ order.shipping_in('USD') # => "$10.00" (with exchange rate)
234
+ ```
235
+
236
+ ### Working with Coin Objects Directly
237
+ ```ruby
238
+ coin = product.price
239
+ coin.currency_code # => "USD"
240
+ coin.currency_minor # => 2999
241
+ coin.amount # => Money object
242
+ coin.amount.format # => "$29.99"
243
+ coin.exchange_to('EUR') # => Money object in EUR
244
+ coin.format(to: 'EUR') # => "€26.85"
245
+ ```
246
+
247
+ ### Validations
248
+ ```ruby
249
+ class Product < ApplicationRecord
250
+ include WhittakerTech::Midas::Bankable
251
+ has_coin :price
252
+
253
+ validate :price_must_be_positive
254
+
255
+ private
256
+
257
+ def price_must_be_positive
258
+ if price_amount && price_amount.cents <= 0
259
+ errors.add(:price, "must be positive")
260
+ end
261
+ end
262
+ end
263
+ ```
264
+
265
+ ## Architecture
266
+
267
+ ```mermaid
268
+ graph TD
269
+ A[Model with Bankable] --> B[has_coin :price]
270
+ B --> C[Coin Record]
271
+ C --> D[Money Object]
272
+ D --> E[Formatting/Display]
273
+ D --> F[Conversions / Exchange Rates]
274
+ ```
275
+
276
+ ### Why This Design?
277
+
278
+ **Problem:** Traditional Rails apps duplicate currency logic everywhere:
279
+ ```ruby
280
+ # ❌ Schema bloat - every model needs these columns
281
+ add_column :products, :price_cents, :integer
282
+ add_column :products, :price_currency, :string
283
+ add_column :invoices, :subtotal_cents, :integer
284
+ add_column :invoices, :subtotal_currency, :string
285
+ # ...repeated dozens of times
286
+ ```
287
+
288
+ **Solution:** Midas uses a polymorphic `Coin` model as a single source of truth:
289
+ ```ruby
290
+ # ✅ One table, unlimited monetary attributes
291
+ create_table :wt_midas_coins do |t|
292
+ t.references :resource, polymorphic: true
293
+ t.string :resource_label # "price", "cost", "tax", etc.
294
+ t.string :currency_code
295
+ t.integer :currency_minor
296
+ end
297
+ ```
298
+
299
+ ### Database Schema
300
+ ```
301
+ ┌─────────────────────────────────────┐
302
+ │ wt_midas_coins │
303
+ ├─────────────────────────────────────┤
304
+ │ id BIGINT │
305
+ │ resource_type STRING │ ─┐
306
+ │ resource_id BIGINT │ ─┤ Polymorphic
307
+ │ resource_label STRING │ ─┘
308
+ │ currency_code STRING(3) │
309
+ │ currency_minor BIGINT │
310
+ │ created_at TIMESTAMP │
311
+ │ updated_at TIMESTAMP │
312
+ └─────────────────────────────────────┘
313
+
314
+ │ has_many :midas_coins
315
+
316
+ ┌────────┴─────────┐
317
+ │ Any Model with │
318
+ │ Bankable │
319
+ └──────────────────┘
320
+ ```
321
+
322
+ ## Testing
323
+
324
+ Run the full test suite:
325
+ ```bash
326
+ cd engines/whittaker_tech-midas
327
+ bundle exec rspec
328
+ ```
329
+
330
+ With coverage report:
331
+ ```bash
332
+ COVERAGE=true bundle exec rspec
333
+ open coverage/index.html
334
+ ```
335
+
336
+ Current coverage: **90%+**
337
+
338
+ ## Development
339
+
340
+ ### Setup
341
+ ```bash
342
+ cd engines/whittaker_tech-midas
343
+ bundle install
344
+ cd spec/dummy
345
+ bin/rails db:create db:migrate
346
+ ```
347
+
348
+ ### Dummy App
349
+
350
+ Test the engine manually:
351
+ ```bash
352
+ cd spec/dummy
353
+ bin/rails server
354
+ # Visit http://localhost:3000
355
+ ```
356
+
357
+ ### Adding New Features
358
+
359
+ 1. Write tests first in `spec/`
360
+ 2. Implement in `app/`
361
+ 3. Update README
362
+ 4. Run `bundle exec rspec`
363
+ 5. Check coverage with `COVERAGE=true bundle exec rspec`
364
+
365
+ ## Troubleshooting
366
+
367
+ ### Exchange rates not working
368
+
369
+ Make sure you've configured exchange rates:
370
+ ```ruby
371
+ Money.default_bank.add_rate('USD', 'EUR', 0.85)
372
+ ```
373
+
374
+ ### Input field not formatting
375
+
376
+ Check that Stimulus is loaded and the controller is registered:
377
+ ```javascript
378
+ import { MidasCurrencyController } from "whittaker_tech-midas"
379
+ application.register("midas-currency", MidasCurrencyController)
380
+ ```
381
+
382
+ ### Coin not persisting
383
+
384
+ Ensure the parent record is saved before setting coins:
385
+ ```ruby
386
+ product = Product.create! # Must be persisted
387
+ product.set_price(amount: 29.99, currency_code: 'USD')
388
+ ```
389
+
390
+ ## Roadmap
391
+
392
+ - [ ] Install generator (`rails g midas:install`)
393
+ - [ ] Add coins generator (`rails g midas:add_coins Product price cost`)
394
+ - [ ] Built-in exchange rate fetching
395
+ - [ ] Coin versioning for audit trails
396
+ - [ ] ViewComponent integration
397
+ - [ ] Stripe/LemonSqueezy integration examples
398
+
399
+ ## Contributing
400
+
401
+ 1. Fork the repository
402
+ 2. Create your feature branch
403
+ 3. Write tests
404
+ 4. Implement your feature
405
+ 5. Submit a pull request
406
+
407
+ ## License
408
+
409
+ MIT License. See [MIT-LICENSE](MIT-LICENSE) for details.
410
+
411
+ ## Credits
412
+
413
+ Built by WhittakerTech
414
+
415
+ Powered by:
416
+ - [Money gem](https://github.com/RubyMoney/money)
417
+ - [Rails](https://rubyonrails.org/)
418
+ - [Stimulus](https://stimulus.hotwired.dev/)
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require 'bundler/setup'
2
+
3
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
4
+ load 'rails/tasks/engine.rake'
5
+
6
+ load 'rails/tasks/statistics.rake'
7
+
8
+ require 'bundler/gem_tasks'
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets/whittaker_tech/midas .css
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,6 @@
1
+ module WhittakerTech
2
+ module Midas
3
+ class ApplicationController < ActionController::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module WhittakerTech
2
+ module Midas
3
+ module ApplicationHelper
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
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)
58
+
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
63
+
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
77
+ end
78
+ end
@@ -0,0 +1 @@
1
+ export { default as MidasCurrencyController } from './midas_currency_controller';
@@ -0,0 +1,60 @@
1
+ // WhittakerTech::Midas Currency Input Controller
2
+ // Provides bank-style currency input behavior where each digit shifts left
3
+ // Example: typing "1234" results in "12.34" (for 2 decimal currencies)
4
+
5
+ import { Controller } from "@hotwired/stimulus"
6
+
7
+ export default class extends Controller {
8
+ static targets = ["display", "hidden"]
9
+ static values = {
10
+ currency: String,
11
+ decimals: { type: Number, default: 2 }
12
+ }
13
+
14
+ connect() {
15
+ this.updateDisplay()
16
+ }
17
+
18
+ // Bank-style typing: each digit shifts left
19
+ input(event) {
20
+ const key = event.data
21
+
22
+ if (!key || !/^\d$/.test(key)) {
23
+ event.preventDefault()
24
+ return
25
+ }
26
+
27
+ let current = parseInt(this.hiddenTarget.value || "0")
28
+ current = (current * 10) + parseInt(key)
29
+
30
+ this.hiddenTarget.value = current
31
+ this.updateDisplay()
32
+ }
33
+
34
+ // Backspace removes the rightmost digit
35
+ backspace(event) {
36
+ if (event.key === "Backspace") {
37
+ event.preventDefault()
38
+ let current = parseInt(this.hiddenTarget.value || "0")
39
+ current = Math.floor(current / 10)
40
+ this.hiddenTarget.value = current
41
+ this.updateDisplay()
42
+ }
43
+ }
44
+
45
+ // Format minor units as major units with decimals
46
+ updateDisplay() {
47
+ const minor = parseInt(this.hiddenTarget.value || "0")
48
+ const divisor = Math.pow(10, this.decimalsValue)
49
+ const amount = (minor / divisor).toFixed(this.decimalsValue)
50
+
51
+ this.displayTarget.value = amount
52
+ }
53
+
54
+ // Prevent default typing, force bank-style
55
+ preventDefault(event) {
56
+ if (event.key !== "Tab" && event.key !== "Backspace") {
57
+ event.preventDefault()
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,6 @@
1
+ module WhittakerTech
2
+ module Midas
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module WhittakerTech
2
+ module Midas
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: 'from@example.com'
5
+ layout 'mailer'
6
+ end
7
+ end
8
+ end