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.
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhittakerTech
4
+ module Midas
5
+ # The Bankable module provides currency and monetary value management functionality
6
+ # for Active Record models. It allows models to have associated monetary values
7
+ # (coins) with different currencies and provides convenient methods for setting,
8
+ # retrieving, and formatting these values.
9
+ #
10
+ # When included in a model, Bankable automatically sets up a polymorphic association
11
+ # to the Midas::Coin model and provides class methods to define specific monetary
12
+ # attributes.
13
+ #
14
+ # @example Basic usage
15
+ # class Product < ApplicationRecord
16
+ # include WhittakerTech::Midas::Bankable
17
+ #
18
+ # has_coin :price
19
+ # end
20
+ #
21
+ # # Create and set a price
22
+ # product = Product.create!
23
+ # product.set_price(amount: 29.99, currency_code: 'USD')
24
+ #
25
+ # # Access the price
26
+ # product.price # => Coin object
27
+ # product.price_amount # => Money object
28
+ # product.price_format # => "$29.99"
29
+ # product.price_in(:eur) # => "€26.85" (if exchange rates available)
30
+ #
31
+ # @example Multiple coins
32
+ # class Invoice < ApplicationRecord
33
+ # include WhittakerTech::Midas::Bankable
34
+ #
35
+ # has_coins :subtotal, :tax, :total
36
+ # end
37
+ #
38
+ # @example Custom dependency handling
39
+ # class Order < ApplicationRecord
40
+ # include WhittakerTech::Midas::Bankable
41
+ #
42
+ # has_coin :deposit, dependent: :nullify
43
+ # end
44
+ #
45
+ # == Associations Created
46
+ #
47
+ # When included, the module automatically creates:
48
+ # - `midas_coins`: A polymorphic has_many association to all Coin records
49
+ # associated with this model instance
50
+ #
51
+ # == Methods Created by has_coin
52
+ #
53
+ # For each coin defined with `has_coin :name`, the following methods are created:
54
+ #
55
+ # - `name`: Returns the associated Coin object
56
+ # - `name_amount`: Returns the Money object representing the amount
57
+ # - `name_format`: Returns a formatted string representation of the amount
58
+ # - `name_in(currency)`: Returns the amount converted to the specified currency
59
+ # - `set_name(amount:, currency_code:)`: Sets the coin value with the given amount and currency
60
+ #
61
+ # == Supported Amount Types
62
+ #
63
+ # The `set_*` methods accept amounts in various formats:
64
+ # - Money objects: Used directly for cents value
65
+ # - Integer: Treated as cents/minor currency units
66
+ # - Numeric: Converted to cents using currency-specific decimal places
67
+ #
68
+ # == Currency Configuration
69
+ #
70
+ # The module uses I18n for currency-specific configuration:
71
+ # - `midas.ui.currencies.{ISO_CODE}.decimal_count`: Decimal places for specific currency
72
+ # - `midas.ui.defaults.decimal_count`: Default decimal places (defaults to 2)
73
+ #
74
+ # == Thread Safety
75
+ #
76
+ # This module is designed to be thread-safe when used with Rails' standard
77
+ # Active Record patterns.
78
+ #
79
+ # @see WhittakerTech::Midas::Coin
80
+ # @since 0.1.0
81
+ module Bankable
82
+ extend ActiveSupport::Concern
83
+
84
+ included do
85
+ has_many :midas_coins,
86
+ as: :resource,
87
+ class_name: 'WhittakerTech::Midas::Coin',
88
+ dependent: :destroy
89
+ end
90
+
91
+ class_methods do
92
+ # Defines multiple coin attributes at once.
93
+ #
94
+ # @param names [Array<Symbol>] The names of the coin attributes to define
95
+ # @param dependent [Symbol] The dependency behavior when the parent record is destroyed
96
+ # (:destroy, :delete_all, :nullify, :restrict_with_exception, :restrict_with_error)
97
+ #
98
+ # @example
99
+ # has_coins :price, :cost, :tax
100
+ # has_coins :deposit, :refund, dependent: :nullify
101
+ def has_coins(*names, dependent: :destroy)
102
+ names.each { |name| has_coin(name, dependent:) }
103
+ end
104
+
105
+ # Defines a single coin attribute with associated methods and database relationship.
106
+ #
107
+ # This method creates:
108
+ # - A has_one association to the Coin model
109
+ # - Getter and setter methods for the coin
110
+ # - Helper methods for amount access and formatting
111
+ #
112
+ # @param name [Symbol] The name of the coin attribute
113
+ # @param dependent [Symbol] The dependency behavior when the parent record is destroyed
114
+ #
115
+ # @example
116
+ # has_coin :price
117
+ # has_coin :refundable_deposit, dependent: :nullify
118
+ def has_coin(name, dependent: :destroy)
119
+ label = name.to_s
120
+ assoc_name = :"#{name}_coin"
121
+
122
+ has_one assoc_name,
123
+ -> { where(resource_label: label) },
124
+ as: :resource,
125
+ class_name: 'WhittakerTech::Midas::Coin',
126
+ dependent: dependent
127
+
128
+ define_methods(name, label, assoc_name)
129
+ end
130
+
131
+ def define_methods(name, label, assoc_name)
132
+ define_method(name) { public_send(assoc_name) }
133
+
134
+ define_method("#{name}_amount") { public_send(name)&.amount }
135
+ define_method("#{name}_format") { public_send(name)&.amount&.format }
136
+ define_method("#{name}_in") { |to| public_send(name)&.exchange_to(to)&.format }
137
+
138
+ # Sets the coin value with the specified amount and currency.
139
+ #
140
+ # @param amount [Money, Integer, Numeric] The amount to set
141
+ # @param currency_code [String, Symbol] The ISO currency code (e.g., 'USD', 'EUR')
142
+ # @return [Coin] The created or updated Coin object
143
+ # @raise [ArgumentError] If the amount type is not supported
144
+ #
145
+ # @example
146
+ # product.set_price(amount: 29.99, currency_code: 'USD')
147
+ # product.set_price(amount: Money.new(2999, 'USD'), currency_code: 'USD')
148
+ # product.set_price(amount: 2999, currency_code: 'USD') # 2999 cents
149
+ define_method("set_#{name}") do |amount:, currency_code:|
150
+ iso = currency_code.to_s.upcase
151
+ cents =
152
+ case amount
153
+ when Money then amount.cents
154
+ when Integer then amount
155
+ when Numeric then (BigDecimal(amount.to_s) * (10**decimals_for(iso))).round.to_i
156
+ else raise ArgumentError, "Invalid value for #{name}: #{amount.inspect}"
157
+ end
158
+
159
+ coin = public_send(name) || public_send("build_#{assoc_name}", resource_label: label)
160
+ coin.currency_code = iso
161
+ coin.currency_minor = cents
162
+ coin.resource = self
163
+ coin.save!
164
+
165
+ coin
166
+ end
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ # Determines the number of decimal places for a given currency.
173
+ #
174
+ # @param iso [String] The ISO currency code
175
+ # @return [Integer] Number of decimal places (0-12)
176
+ def decimals_for(iso)
177
+ scope = 'midas.ui'
178
+ per = I18n.t("#{scope}.currencies.#{iso}", default: {})
179
+ default = I18n.t("#{scope}.defaults.decimal_count", default: 2)
180
+ (per['decimal_count'] || default).to_i.clamp(0, 12)
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,7 @@
1
+ module WhittakerTech
2
+ module Midas
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
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
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Whittaker tech midas</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= stylesheet_link_tag "whittaker_tech/midas/application", media: "all" %>
9
+ </head>
10
+ <body>
11
+
12
+ <%= yield %>
13
+
14
+ </body>
15
+ </html>
@@ -0,0 +1,49 @@
1
+ <%
2
+ # Headless currency input field for Money-like objects.
3
+ #
4
+ # Locals:
5
+ # form: FormBuilder
6
+ # attribute: Symbol, e.g. :price
7
+ # value: Money object (responds to #fractional and #currency.iso_code)
8
+ # decimals: Integer (default: 2)
9
+ # input_html: Hash (optional)
10
+ # wrapper_html: Hash (optional)
11
+ # label_text: String (optional, defaults to humanized attribute)
12
+ # show_label: Boolean (default: false)
13
+ #
14
+ input_id = "#{form.object_name}_#{attribute}_display"
15
+ money_value = value || Money.new(0, "USD")
16
+ current_minor = money_value.fractional
17
+ currency_code = money_value.currency.iso_code
18
+ decimals ||= 2
19
+ input_html ||= {}
20
+ wrapper_html ||= {}
21
+ show_label ||= false
22
+ label_text = label_text.presence || attribute.to_s.humanize
23
+ %>
24
+
25
+ <div <%= tag.attributes(wrapper_html) %>
26
+ data-controller="midas-currency"
27
+ data-midas-currency-currency-value="<%= currency_code %>"
28
+ data-midas-currency-decimals-value="<%= decimals %>">
29
+
30
+ <%= form.label "#{attribute}_display",
31
+ label_text,
32
+ for: input_id,
33
+ class: (show_label ? nil : "sr-only") %>
34
+
35
+ <input type="text"
36
+ id="<%= input_id %>"
37
+ name="<%= form.object_name %>[<%= attribute %>_display]"
38
+ data-midas-currency-target="display"
39
+ data-action="beforeinput->midas-currency#input keydown->midas-currency#backspace keydown->midas-currency#preventDefault"
40
+ inputmode="decimal"
41
+ autocomplete="off"
42
+ <%= tag.attributes(input_html) %>>
43
+
44
+ <%= form.hidden_field "#{attribute}_minor",
45
+ value: current_minor,
46
+ data: { midas_currency_target: "hidden" } %>
47
+
48
+ <%= form.hidden_field "#{attribute}_currency", value: currency_code %>
49
+ </div>
@@ -0,0 +1,58 @@
1
+ # WhittakerTech::Midas I18n Configuration
2
+ #
3
+ # This file defines currency-specific settings used by the Bankable concern
4
+ # for decimal precision when converting numeric values to minor units.
5
+ #
6
+ # Add more currencies as needed following the ISO 4217 standard.
7
+
8
+ en:
9
+ midas:
10
+ ui:
11
+ defaults:
12
+ decimal_count: 2
13
+ currencies:
14
+ # Major currencies
15
+ USD:
16
+ decimal_count: 2
17
+ symbol: "$"
18
+ symbol_position: left
19
+ EUR:
20
+ decimal_count: 2
21
+ symbol: "€"
22
+ symbol_position: left
23
+ GBP:
24
+ decimal_count: 2
25
+ symbol: "£"
26
+ symbol_position: left
27
+ JPY:
28
+ decimal_count: 0
29
+ symbol: "¥"
30
+ symbol_position: left
31
+ CNY:
32
+ decimal_count: 2
33
+ symbol: "¥"
34
+ symbol_position: left
35
+
36
+ # Additional currencies
37
+ CAD:
38
+ decimal_count: 2
39
+ symbol: "$"
40
+ symbol_position: left
41
+ AUD:
42
+ decimal_count: 2
43
+ symbol: "$"
44
+ symbol_position: left
45
+ CHF:
46
+ decimal_count: 2
47
+ symbol: "Fr"
48
+ symbol_position: left
49
+ INR:
50
+ decimal_count: 2
51
+ symbol: "₹"
52
+ symbol_position: left
53
+
54
+ # Cryptocurrency (if needed)
55
+ BTC:
56
+ decimal_count: 8
57
+ symbol: "₿"
58
+ symbol_position: left
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ WhittakerTech::Midas::Engine.routes.draw do
2
+ end
@@ -0,0 +1,16 @@
1
+ class CreateWtMidasCoins < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :wt_midas_coins do |t|
4
+ t.references :resource, polymorphic: true, null: false, index: true
5
+ t.string :resource_label, null: false, limit: 64
6
+ t.string :currency_code, null: false, limit: 3
7
+ t.bigint :currency_minor, null: false
8
+
9
+ t.timestamps
10
+
11
+ t.index %i[resource_id resource_type resource_label],
12
+ unique: true,
13
+ name: 'index_wt_midas_coins_on_owner_and_label'
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :whittaker_tech_midas do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhittakerTech
4
+ module Midas
5
+ # The Midas Engine provides monetary value management capabilities as a Rails Engine.
6
+ # It integrates seamlessly with Rails applications to add currency handling, coin management,
7
+ # and monetary formatting functionality through the Bankable concern and related components.
8
+ #
9
+ # This engine follows Rails Engine conventions and can be mounted in any Rails application
10
+ # to provide comprehensive monetary value handling capabilities. It includes models, helpers,
11
+ # and utilities for working with currencies and monetary amounts.
12
+ #
13
+ # == Features
14
+ #
15
+ # - **Bankable Concern**: Allows any Active Record model to have monetary attributes
16
+ # - **Coin Model**: Stores monetary values with currency information
17
+ # - **Form Helpers**: View helpers for monetary input and display
18
+ # - **Currency Management**: Integration with the Money gem for currency operations
19
+ # - **Namespace Isolation**: Clean separation from host application code
20
+ #
21
+ # == Installation and Setup
22
+ #
23
+ # Add the engine to your Rails application's Gemfile and run bundle install.
24
+ # The engine will automatically configure itself when Rails boots.
25
+ #
26
+ # @example Adding to a Rails application
27
+ # # In your Gemfile
28
+ # gem 'whittaker_tech-midas'
29
+ #
30
+ # # The engine auto-configures on Rails boot
31
+ # # No additional setup required
32
+ #
33
+ # == Usage in Host Applications
34
+ #
35
+ # Once installed, the engine's functionality becomes available throughout
36
+ # your Rails application:
37
+ #
38
+ # @example Using Bankable in models
39
+ # class Product < ApplicationRecord
40
+ # include WhittakerTech::Midas::Bankable
41
+ # has_coin :price
42
+ # end
43
+ #
44
+ # @example Using form helpers in views
45
+ # <%= form_with model: @product do |form| %>
46
+ # <%= form.money_field :price %>
47
+ # <% end %>
48
+ #
49
+ # == Configuration
50
+ #
51
+ # The engine provides several configuration points:
52
+ #
53
+ # - **Eager Loading**: Automatically configures model loading paths
54
+ # - **Helper Integration**: Injects form helpers into ActionView
55
+ # - **Namespace Isolation**: Keeps engine code separate from host application
56
+ #
57
+ # == Directory Structure
58
+ #
59
+ # The engine follows standard Rails Engine structure:
60
+ # - `app/models/`: Coin model and concerns
61
+ # - `app/helpers/`: Form helpers for monetary inputs
62
+ # - `db/migrate/`: Database migrations for coin storage
63
+ # - `lib/`: Engine configuration and initialization
64
+ #
65
+ # == Database Integration
66
+ #
67
+ # The engine provides database migrations that create the necessary tables
68
+ # for storing monetary values. Run migrations after installation:
69
+ #
70
+ # @example Running migrations
71
+ # rails db:migrate
72
+ #
73
+ # == Namespace Isolation
74
+ #
75
+ # The engine uses `isolate_namespace` to prevent conflicts with host
76
+ # application code. All engine components are properly namespaced under
77
+ # `WhittakerTech::Midas`.
78
+ #
79
+ # == Helper Integration
80
+ #
81
+ # Form helpers are automatically made available in all views through
82
+ # an initializer that extends ActionView::Base when the view layer loads.
83
+ # This provides seamless integration without requiring manual includes.
84
+ #
85
+ # == Eager Loading
86
+ #
87
+ # The engine configures additional eager load paths to ensure all models
88
+ # are properly loaded in production environments. This includes the models
89
+ # directory which contains the Coin model and Bankable concern.
90
+ #
91
+ # == Thread Safety
92
+ #
93
+ # The engine follows Rails conventions for thread safety and can be safely
94
+ # used in multi-threaded environments like Puma or Falcon.
95
+ #
96
+ # == Development and Testing
97
+ #
98
+ # The engine can be developed and tested independently using its own
99
+ # test suite, or integrated into a host application for testing.
100
+ #
101
+ # @see WhittakerTech::Midas::Bankable
102
+ # @see WhittakerTech::Midas::Coin
103
+ # @see WhittakerTech::Midas::FormHelper
104
+ # @since 0.1.0
105
+ class Engine < ::Rails::Engine
106
+ isolate_namespace WhittakerTech::Midas
107
+
108
+ config.eager_load_paths += Dir["#{config.root}/app/models"]
109
+
110
+ initializer 'midas.helpers' do
111
+ ActiveSupport.on_load(:action_view) do
112
+ include WhittakerTech::Midas::FormHelper
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,5 @@
1
+ module WhittakerTech
2
+ module Midas
3
+ VERSION = '0.1.0'.freeze
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'whittaker_tech/midas/version'
4
+ require 'whittaker_tech/midas/engine'
5
+ require 'money'
6
+
7
+ module WhittakerTech
8
+ module Midas
9
+ # Your code goes here...
10
+ end
11
+ end