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 +7 -0
- data/README.md +418 -0
- data/Rakefile +8 -0
- data/app/assets/config/whittaker_tech_midas_manifest.js +1 -0
- data/app/assets/stylesheets/whittaker_tech/midas/application.css +15 -0
- data/app/controllers/whittaker_tech/midas/application_controller.rb +6 -0
- data/app/helpers/whittaker_tech/midas/application_helper.rb +6 -0
- data/app/helpers/whittaker_tech/midas/form_helper.rb +78 -0
- data/app/javascript/controllers/index.js +1 -0
- data/app/javascript/controllers/midas_currency_controller.js +60 -0
- data/app/jobs/whittaker_tech/midas/application_job.rb +6 -0
- data/app/mailers/whittaker_tech/midas/application_mailer.rb +8 -0
- data/app/models/concerns/whittaker_tech/midas/bankable.rb +184 -0
- data/app/models/whittaker_tech/midas/application_record.rb +7 -0
- data/app/models/whittaker_tech/midas/coin.rb +81 -0
- data/app/views/layouts/whittaker_tech/midas/application.html.erb +15 -0
- data/app/views/layouts/whittaker_tech/midas/shared/_currency_field.html.erb +49 -0
- data/config/locales/midas.en.yml +58 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20251015160523_create_wt_midas_coins.rb +16 -0
- data/lib/tasks/whittaker_tech/midas_tasks.rake +4 -0
- data/lib/whittaker_tech/midas/engine.rb +117 -0
- data/lib/whittaker_tech/midas/version.rb +5 -0
- data/lib/whittaker_tech/midas.rb +11 -0
- metadata +212 -0
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)
|
|
4
|
+
[]()
|
|
5
|
+
[]()
|
|
6
|
+
[](https://badge.fury.io/rb/whittaker_tech-midas)
|
|
7
|
+
[](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 @@
|
|
|
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,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
|
+
}
|