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
@@ -1,189 +1,205 @@
1
1
  # frozen_string_literal: true
2
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
3
+ # The Bankable module provides currency and monetary value management functionality
4
+ # for Active Record models. It allows models to have associated monetary values
5
+ # (coins) with different currencies and provides convenient methods for setting,
6
+ # retrieving, and formatting these values.
7
+ #
8
+ # When included in a model, Bankable automatically sets up a polymorphic association
9
+ # to the Midas::Coin model and provides class methods to define specific monetary
10
+ # attributes.
11
+ #
12
+ # @example Basic usage
13
+ # class Product < ApplicationRecord
14
+ # include WhittakerTech::Midas::Bankable
15
+ #
16
+ # has_coin :price
17
+ # end
18
+ #
19
+ # # Create and set a price
20
+ # product = Product.create!
21
+ # product.set_price(amount: 29.99, currency_code: 'USD')
22
+ #
23
+ # # Access the price
24
+ # product.price # => Coin object
25
+ # product.price_amount # => Money object
26
+ # product.price_format # => "$29.99"
27
+ #
28
+ # @example Multiple coins
29
+ # class Invoice < ApplicationRecord
30
+ # include WhittakerTech::Midas::Bankable
31
+ #
32
+ # has_coins :subtotal, :tax, :total
33
+ # end
34
+ #
35
+ # @example Custom dependency handling
36
+ # class Order < ApplicationRecord
37
+ # include WhittakerTech::Midas::Bankable
38
+ #
39
+ # has_coin :deposit, dependent: :nullify
40
+ # end
41
+ #
42
+ # == Associations Created
43
+ #
44
+ # When included, the module automatically creates:
45
+ # - `midas_coins`: A polymorphic has_many association to all Coin records
46
+ # associated with this model instance
47
+ #
48
+ # == Methods Created by has_coin
49
+ #
50
+ # For each coin defined with `has_coin :name`, the following methods are created:
51
+ #
52
+ # - `name`: Returns the associated Coin object
53
+ # - `name_amount`: Returns the Money object representing the amount
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`)
57
+ # - `set_name(amount:, currency_code:)`: Sets the coin value with the given amount and currency
58
+ #
59
+ # == Supported Amount Types
60
+ #
61
+ # The `set_*` methods accept amounts in various formats:
62
+ # - Money objects: Used directly for cents value
63
+ # - Integer: Treated as cents/minor currency units
64
+ # - Numeric: Converted to cents using currency-specific decimal places
65
+ #
66
+ # == Currency Configuration
67
+ #
68
+ # The module uses I18n for currency-specific configuration:
69
+ # - `midas.ui.currencies.<ISO_CODE>.decimal_count`: Decimal places for specific currency
70
+ # - `midas.ui.defaults.decimal_count`: Default decimal places (defaults to 2)
71
+ #
72
+ # == Thread Safety
73
+ #
74
+ # This module is designed to be thread-safe when used with Rails' standard
75
+ # Active Record patterns.
76
+ #
77
+ # See also: `WhittakerTech::Midas::Coin`
78
+ # @since 0.1.0
79
+ module WhittakerTech::Midas::Bankable
80
+ extend ActiveSupport::Concern
81
+
82
+ included do
83
+ has_many :midas_coins,
84
+ as: :resource,
85
+ class_name: 'WhittakerTech::Midas::Coin',
86
+ dependent: :destroy
87
+ end
88
+
89
+ class_methods do
90
+ # Defines multiple coin attributes at once.
67
91
  #
68
- # == Currency Configuration
92
+ # @param names [Array<Symbol>] The names of the coin attributes to define
93
+ # @param dependent [Symbol] The dependency behavior when the parent record is destroyed
94
+ # (:destroy, :delete_all, :nullify, :restrict_with_exception, :restrict_with_error)
69
95
  #
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)
96
+ # @example
97
+ # has_coins :price, :cost, :tax
98
+ # has_coins :deposit, :refund, dependent: :nullify
99
+ def has_coins(*names, dependent: :destroy)
100
+ names.each { |name| has_coin(name, dependent:) }
101
+ end
102
+
103
+ # Defines a single coin attribute with associated methods and database relationship.
73
104
  #
74
- # == Thread Safety
105
+ # This method creates:
106
+ # - A has_one association to the Coin model
107
+ # - Getter and setter methods for the coin
108
+ # - Helper methods for amount access and formatting
75
109
  #
76
- # This module is designed to be thread-safe when used with Rails' standard
77
- # Active Record patterns.
110
+ # @param name [Symbol] The name of the coin attribute
111
+ # @param dependent [Symbol] The dependency behavior when the parent record is destroyed
78
112
  #
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
113
+ # @example
114
+ # has_coin :price
115
+ # has_coin :refundable_deposit, dependent: :nullify
116
+ def has_coin(name, dependent: :destroy)
117
+ label = name.to_s
118
+ assoc_name = :"#{name}_coin"
90
119
 
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
- coin = public_send(name) || public_send("build_#{assoc_name}", resource_label: label)
152
- coin.currency_code = iso
153
- coin.currency_minor = to_cents(name, amount, iso)
154
- coin.resource = self
155
- coin.save!
156
-
157
- coin
158
- end
159
- end
160
- end
120
+ has_one assoc_name,
121
+ -> { for_role(label) },
122
+ as: :resource,
123
+ class_name: 'WhittakerTech::Midas::Coin',
124
+ dependent: dependent
161
125
 
162
- private
163
-
164
- def to_cents(name, amount, iso)
165
- raise ArgumentError, "Invalid value for #{name}: #{amount.inspect}" unless is_valid_type?(amount)
126
+ define_methods(name, label, assoc_name)
127
+ end
166
128
 
167
- return amount.cents if amount.is_a? Money
168
- return amount if amount.is_a? Integer
129
+ def define_methods(name, label, assoc_name)
130
+ define_method(name) { public_send(assoc_name) }
169
131
 
170
- (BigDecimal(amount.to_s) * (10**decimals_for(iso))).round.to_i
171
- end
132
+ define_method("#{name}_amount") { public_send(name)&.amount }
133
+ define_method("#{name}_format") { public_send(name)&.amount&.format }
134
+ define_method("#{name}_in") { |to| public_send(name)&.exchange_to(to)&.format }
172
135
 
173
- def is_valid_type?(amount)
174
- [Money, Integer, Numeric].any? { |klass| amount.is_a?(klass) }
175
- end
176
-
177
- # Determines the number of decimal places for a given currency.
136
+ # Sets the coin value with the specified amount and currency.
137
+ #
138
+ # @param amount [Money, Integer, Numeric] The amount to set
139
+ # @param currency_code [String, Symbol] The ISO currency code (e.g., 'USD', 'EUR')
140
+ # @return [Coin] The created or updated Coin object
141
+ # @raise [ArgumentError] If the amount type is not supported
178
142
  #
179
- # @param iso [String] The ISO currency code
180
- # @return [Integer] Number of decimal places (0-12)
181
- def decimals_for(iso)
182
- scope = 'midas.ui'
183
- per = I18n.t("#{scope}.currencies.#{iso}", default: {})
184
- default = I18n.t("#{scope}.defaults.decimal_count", default: 2)
185
- (per['decimal_count'] || default).to_i.clamp(0, 12)
143
+ # @example
144
+ # product.set_price(amount: 29.99, currency_code: 'USD')
145
+ # product.set_price(amount: Money.new(2999, 'USD'), currency_code: 'USD')
146
+ # product.set_price(amount: 2999, currency_code: 'USD') # 2999 cents
147
+ define_method("set_#{name}") do |amount:, currency_code:|
148
+ iso = currency_code.to_s.upcase
149
+ coin = public_send(name) || public_send("build_#{assoc_name}", resource_role: label)
150
+ coin.currency_code = iso
151
+ coin.currency_minor = to_cents(name, amount, iso)
152
+ coin.resource = self
153
+ coin.save!
154
+
155
+ coin
186
156
  end
187
157
  end
188
158
  end
159
+
160
+ private
161
+
162
+ # Converts an amount in various representations to an integer minor-unit count.
163
+ #
164
+ # Input conversion rules:
165
+ #
166
+ # - `Money`: Uses `money.cents` directly (already minor units)
167
+ # - `Integer`: Assumed to already be minor units; returned as-is
168
+ # - `Numeric`: Treated as major units; scaled by `10 ** decimals_for`
169
+ #
170
+ # @param name [Symbol] attribute name used in error messages
171
+ # @param amount [Money, Integer, Numeric] the amount to convert
172
+ # @param iso [String] uppercased ISO 4217 currency code
173
+ # @return [Integer] minor-unit count (e.g. cents)
174
+ # @raise [ArgumentError] if `amount` is not a supported type
175
+ def to_cents(name, amount, iso)
176
+ raise ArgumentError, "Invalid value for #{name}: #{amount.inspect}" unless is_valid_type?(amount)
177
+
178
+ return amount.cents if amount.is_a? Money
179
+ return amount if amount.is_a? Integer
180
+
181
+ (BigDecimal(amount.to_s) * (10**decimals_for(iso))).round.to_i
182
+ end
183
+
184
+ # Returns true if `amount` is one of the accepted input types.
185
+ # @param amount [Object]
186
+ # @return [Boolean]
187
+ def is_valid_type?(amount)
188
+ [Money, Integer, Numeric].any? { |klass| amount.is_a?(klass) }
189
+ end
190
+
191
+ # Determines the number of decimal places for a given currency.
192
+ #
193
+ # Reads from `midas.ui.currencies.<ISO>.decimal_count` in I18n, falling
194
+ # back to `midas.ui.defaults.decimal_count` (default: `2`). Clamped to
195
+ # `[0, 12]` to prevent pathological scaling.
196
+ #
197
+ # @param iso [String] The ISO currency code
198
+ # @return [Integer] Number of decimal places (0-12)
199
+ def decimals_for(iso)
200
+ scope = 'midas.ui'
201
+ per = I18n.t("#{scope}.currencies.#{iso}", default: {})
202
+ default = I18n.t("#{scope}.defaults.decimal_count", default: 2)
203
+ (per['decimal_count'] || default).to_i.clamp(0, 12)
204
+ end
189
205
  end
@@ -1,7 +1,3 @@
1
- module WhittakerTech
2
- module Midas
3
- class ApplicationRecord < ActiveRecord::Base
4
- self.abstract_class = true
5
- end
6
- end
1
+ class WhittakerTech::Midas::ApplicationRecord < ActiveRecord::Base
2
+ self.abstract_class = true
7
3
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Allocation represents a per-unit interpretation of a `Coin`.
4
+ #
5
+ # ## Conceptual model
6
+ #
7
+ # ```
8
+ # Coin -> canonical, payable money (what exists)
9
+ # Allocation -> "this Coin, spread across N units, using policy X"
10
+ # ```
11
+ #
12
+ # An Allocation answers two questions:
13
+ #
14
+ # 1. **What does one unit cost?** — `#value`
15
+ # 2. **What does a given quantity cost?** — `#price`
16
+ #
17
+ # ## Immutability
18
+ #
19
+ # Allocation is:
20
+ # - **immutable** — `@coin`, `@divisor`, and `@rounding_policy` are frozen on
21
+ # construction and never mutated.
22
+ # - **not persisted** — Allocation is a pure value object.
23
+ # - **minor-unit safe** — it never stores sub-minor-unit amounts; rounding is
24
+ # applied before returning a Coin.
25
+ #
26
+ # @example Per-unit price from a bulk price
27
+ # bulk = Coin.value(10_000, 'USD') # $100.00 for a pack of 6
28
+ # alloc = bulk.allocate(per: 6, rounding_policy: :ceil)
29
+ # alloc.value # => Coin($16.67)
30
+ # alloc.price(qty: 2) # => Coin($33.34)
31
+ #
32
+ # See also: `Coin#allocate`
33
+ # @since 0.1.0
34
+ class WhittakerTech::Midas::Coin::Allocation
35
+ # @return [Coin] the total monetary amount this Allocation is based on
36
+ attr_reader :coin
37
+
38
+ # @return [Numeric] the number of units the coin covers (the divisor)
39
+ attr_reader :divisor
40
+
41
+ # @return [Symbol] the rounding policy applied to per-unit calculations
42
+ attr_reader :rounding_policy
43
+
44
+ # Alias for `#divisor`. Reads as "this coin, per N units".
45
+ alias per divisor
46
+
47
+ # Delegate invariant facts from the underlying Coin.
48
+ # These values do not change under scaling.
49
+ delegate :currency_code,
50
+ :currency,
51
+ :decimals,
52
+ :scale,
53
+ :zero?,
54
+ :positive?,
55
+ :negative?,
56
+ to: :coin
57
+
58
+ # @param coin [Coin] the total monetary value
59
+ # @param divisor [Numeric] the number of units; must be positive
60
+ # @param rounding_policy [Symbol] one of `WhittakerTech::Midas::ROUNDING_POLICIES`
61
+ # @raise [TypeError] if `coin` is not a `Coin`
62
+ # @raise [TypeError] if `divisor` is not a positive Numeric
63
+ # @raise [ArgumentError] if `rounding_policy` is not a recognised policy key
64
+ def initialize(coin:, divisor:, rounding_policy:)
65
+ raise TypeError unless coin.is_a?(WhittakerTech::Midas::Coin)
66
+ raise TypeError unless divisor.is_a?(Numeric) && divisor.positive?
67
+ raise ArgumentError unless WhittakerTech::Midas::ROUNDING_POLICIES.key?(rounding_policy)
68
+
69
+ @coin = coin.freeze
70
+ @divisor = divisor
71
+ @rounding_policy = rounding_policy.freeze
72
+ end
73
+
74
+ # Returns the per-unit Coin after dividing by `#divisor` and applying the
75
+ # `#rounding_policy`.
76
+ #
77
+ # The result is still a `Coin` — payable money representing a single unit.
78
+ #
79
+ # @return [Coin]
80
+ def value
81
+ coin.divide(divisor, rounding_policy: rounding_policy)
82
+ end
83
+
84
+ # Returns the total price for a given quantity of units.
85
+ #
86
+ # Uses `(total_minor * qty) / divisor` rounded via `#rounding_policy`.
87
+ # Equivalent to `qty` units at the per-unit rate, but computed from the
88
+ # original total to minimise accumulated rounding error.
89
+ #
90
+ # @param qty [Numeric] the number of units; must be >= 0
91
+ # @return [Coin]
92
+ # @raise [ArgumentError] if `qty` is negative or not Numeric
93
+ #
94
+ # @example
95
+ # alloc = Coin.value(10_000, 'USD').allocate(per: 6)
96
+ # alloc.price(qty: 3) # => Coin($50.00) (10000 * 3 / 6 = 5000 cents)
97
+ def price(qty: 1)
98
+ raise ArgumentError unless qty.is_a?(Numeric) && qty >= 0
99
+
100
+ WhittakerTech::Midas::Coin.value(
101
+ round((coin.currency_minor * qty) / divisor),
102
+ coin.currency_code
103
+ ).freeze
104
+ end
105
+
106
+ # Human-readable representation for logs and console output.
107
+ # @return [String]
108
+ def inspect
109
+ "#<#{self.class.name} coin=#{coin.inspect} divisor=#{divisor} rounding_policy=#{rounding_policy}>"
110
+ end
111
+
112
+ private
113
+
114
+ # Applies the configured `#rounding_policy` to a raw Numeric amount and
115
+ # returns an Integer suitable for Coin construction.
116
+ # @param amount [Numeric]
117
+ # @return [Integer]
118
+ def round(amount)
119
+ WhittakerTech::Midas::ROUNDING_POLICIES
120
+ .fetch(rounding_policy)
121
+ .call(amount)
122
+ .to_i
123
+ end
124
+ end