usage_credits 0.5.0 → 1.0.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +120 -29
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +64 -20
  6. data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
  7. data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
  8. data/lib/usage_credits/callbacks.rb +9 -54
  9. data/lib/usage_credits/configuration.rb +25 -13
  10. data/lib/usage_credits/core_ext/numeric.rb +12 -16
  11. data/lib/usage_credits/cost/base.rb +1 -6
  12. data/lib/usage_credits/cost/compound.rb +3 -2
  13. data/lib/usage_credits/cost/fixed.rb +1 -2
  14. data/lib/usage_credits/cost/variable.rb +23 -8
  15. data/lib/usage_credits/engine.rb +8 -12
  16. data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
  17. data/lib/usage_credits/helpers/credits_helper.rb +9 -11
  18. data/lib/usage_credits/helpers/period_parser.rb +31 -9
  19. data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
  21. data/lib/usage_credits/models/allocation.rb +15 -20
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
  25. data/lib/usage_credits/models/credit_pack.rb +34 -13
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
  27. data/lib/usage_credits/models/fulfillment.rb +23 -48
  28. data/lib/usage_credits/models/operation.rb +36 -28
  29. data/lib/usage_credits/models/transaction.rb +51 -158
  30. data/lib/usage_credits/models/transfer.rb +37 -0
  31. data/lib/usage_credits/models/wallet.rb +219 -314
  32. data/lib/usage_credits/railtie.rb +4 -13
  33. data/lib/usage_credits/services/fulfillment_service.rb +146 -22
  34. data/lib/usage_credits/subscription_terms.rb +131 -0
  35. data/lib/usage_credits/version.rb +1 -1
  36. data/lib/usage_credits.rb +16 -3
  37. metadata +33 -20
  38. data/.rubocop.yml +0 -8
  39. data/.simplecov +0 -37
  40. data/AGENTS.md +0 -5
  41. data/Appraisals +0 -43
  42. data/CLAUDE.md +0 -5
  43. data/Rakefile +0 -32
  44. data/gemfiles/pay_10.0.gemfile +0 -41
  45. data/gemfiles/pay_11.0.gemfile +0 -41
  46. data/gemfiles/pay_8.3.gemfile +0 -41
  47. data/gemfiles/pay_9.0.gemfile +0 -41
  48. data/gemfiles/rails_7.2.gemfile +0 -41
  49. data/gemfiles/rails_8.1.gemfile +0 -41
@@ -1,67 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UsageCredits
4
- # Centralized callback dispatch module
5
- # Handles executing callbacks with error isolation
4
+ # usage_credits adapter for wallets' shared callback dispatcher.
6
5
  module Callbacks
7
- module_function
8
-
9
- # Dispatch a callback event with error isolation
10
- # Callbacks should never break the main operation
11
- #
12
- # @param event [Symbol] The event type (e.g., :credits_added, :low_balance_reached)
13
- # @param context_data [Hash] Data to pass to the callback via CallbackContext
14
- def dispatch(event, **context_data)
15
- config = UsageCredits.configuration
16
- callback = config.public_send(:"on_#{event}_callback")
17
-
18
- return unless callback.is_a?(Proc)
19
-
20
- context = CallbackContext.new(event: event, **context_data)
6
+ extend Wallets::CallbackDispatcher
21
7
 
22
- execute_safely(callback, context)
23
- end
24
-
25
- # Execute callback with error isolation and arity handling
26
- #
27
- # @param callback [Proc] The callback to execute
28
- # @param context [CallbackContext] The context to pass
29
- def execute_safely(callback, context)
30
- case callback.arity
31
- when 1, -1, -2 # Accepts one arg or variable args
32
- callback.call(context)
33
- when 0
34
- callback.call
35
- else
36
- log_warn "[UsageCredits] Callback has unexpected arity (#{callback.arity}). Expected 0 or 1."
37
- end
38
- rescue StandardError => e
39
- # Log but don't re-raise - callbacks should never break credit operations
40
- log_error "[UsageCredits] Callback error for #{context.event}: #{e.class}: #{e.message}"
41
- log_debug e.backtrace.join("\n")
42
- end
8
+ module_function
43
9
 
44
- # Safe logging that works with or without Rails
45
- def log_error(message)
46
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
47
- Rails.logger.error(message)
48
- else
49
- warn message
50
- end
10
+ def callback_configuration
11
+ UsageCredits.configuration
51
12
  end
52
13
 
53
- def log_warn(message)
54
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
55
- Rails.logger.warn(message)
56
- else
57
- warn message
58
- end
14
+ def callback_context_class
15
+ UsageCredits::CallbackContext
59
16
  end
60
17
 
61
- def log_debug(message)
62
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug?
63
- Rails.logger.debug(message)
64
- end
18
+ def callback_log_prefix
19
+ "[UsageCredits]"
65
20
  end
66
21
  end
67
22
  end
@@ -33,6 +33,12 @@ module UsageCredits
33
33
  # Custom transaction categories that extend the default set
34
34
  attr_reader :additional_categories
35
35
 
36
+ # Table prefix for usage_credits tables (for wallets gem compatibility)
37
+ # Note: usage_credits uses fixed table names, so this is always "usage_credits_"
38
+ def table_prefix
39
+ "usage_credits_"
40
+ end
41
+
36
42
  # Minimum allowed fulfillment period for subscription plans.
37
43
  # Defaults to 1.day to prevent accidental 1-second refill loops in production.
38
44
  # Can be set to shorter periods (e.g., 2.seconds) in development/test for faster iteration.
@@ -53,12 +59,12 @@ module UsageCredits
53
59
  # =========================================
54
60
 
55
61
  attr_reader :on_credits_added_callback,
56
- :on_credits_deducted_callback,
57
- :on_low_balance_reached_callback,
58
- :on_balance_depleted_callback,
59
- :on_insufficient_credits_callback,
60
- :on_subscription_credits_awarded_callback,
61
- :on_credit_pack_purchased_callback
62
+ :on_credits_deducted_callback,
63
+ :on_low_balance_reached_callback,
64
+ :on_balance_depleted_callback,
65
+ :on_insufficient_credits_callback,
66
+ :on_subscription_credits_awarded_callback,
67
+ :on_credit_pack_purchased_callback
62
68
 
63
69
  def initialize
64
70
  # Initialize empty data stores
@@ -159,15 +165,18 @@ module UsageCredits
159
165
  def default_currency=(value)
160
166
  value = value.to_s.downcase.to_sym
161
167
  unless VALID_CURRENCIES.include?(value)
162
- raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(', ')}"
168
+ raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(", ")}"
163
169
  end
164
170
  @default_currency = value
165
171
  end
166
172
 
167
- # Set low balance threshold with validation
173
+ # Set low balance threshold with validation. Accepts plain integers and
174
+ # the DSL form the initializer template shows (`100.credits`, a
175
+ # Cost::Fixed) — anything else fails WholeNumber.parse loudly.
168
176
  def low_balance_threshold=(value)
169
177
  if value
170
- value = value.to_i
178
+ value = value.to_i if value.is_a?(UsageCredits::Cost::Fixed)
179
+ value = Wallets::WholeNumber.parse(value, name: "Low balance threshold", allow_string: true)
171
180
  raise ArgumentError, "Low balance threshold must be greater than or equal to zero" if value.negative?
172
181
  end
173
182
  @low_balance_threshold = value
@@ -183,7 +192,10 @@ module UsageCredits
183
192
  end
184
193
 
185
194
  def fulfillment_grace_period=(value)
186
- if value.nil? || value&.to_i == 0
195
+ # Only actual nil/numeric zero select the safe one-second fallback.
196
+ # String coercion previously made arbitrary garbage ("nope".to_i == 0)
197
+ # silently valid configuration; non-Duration strings now fail closed.
198
+ if value.nil? || value == 0
187
199
  @fulfillment_grace_period = 1.second
188
200
  return
189
201
  end
@@ -296,19 +308,19 @@ module UsageCredits
296
308
  def validate_currency!
297
309
  raise ArgumentError, "Default currency can't be blank" if default_currency.blank?
298
310
  unless VALID_CURRENCIES.include?(default_currency.to_s.downcase.to_sym)
299
- raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(', ')}"
311
+ raise ArgumentError, "Invalid currency. Must be one of: #{VALID_CURRENCIES.join(", ")}"
300
312
  end
301
313
  end
302
314
 
303
315
  def validate_threshold!
304
- if @low_balance_threshold && @low_balance_threshold.negative?
316
+ if @low_balance_threshold&.negative?
305
317
  raise ArgumentError, "Low balance threshold must be greater than or equal to zero"
306
318
  end
307
319
  end
308
320
 
309
321
  def validate_rounding_strategy!
310
322
  unless VALID_ROUNDING_STRATEGIES.include?(@rounding_strategy)
311
- raise ArgumentError, "Invalid rounding strategy. Must be one of: #{VALID_ROUNDING_STRATEGIES.join(', ')}"
323
+ raise ArgumentError, "Invalid rounding strategy. Must be one of: #{VALID_ROUNDING_STRATEGIES.join(", ")}"
312
324
  end
313
325
  end
314
326
 
@@ -7,28 +7,24 @@ require "active_support/core_ext/numeric"
7
7
  # (Cost::Base, Cost::Fixed, Cost::Variable, Cost::Compound, etc.)
8
8
  class Numeric
9
9
  def credits
10
- raise ArgumentError, "Credit amount must be a whole number (decimals are not allowed)" unless self == self.to_i
11
- raise ArgumentError, "Credit amount cannot be negative" if self.negative?
12
- UsageCredits::Cost::Fixed.new(self.to_i)
10
+ UsageCredits::Cost::Fixed.new(self)
13
11
  end
14
12
  alias_method :credit, :credits
15
13
 
16
14
  def credits_per(unit)
17
- raise ArgumentError, "Credit cost rate must be a whole number (decimals are not allowed)" unless self == self.to_i
18
-
19
15
  # Convert common units to their base unit
20
16
  unit = case unit.to_s.downcase
21
- when "mb", "megabyte", "megabytes"
22
- :mb
23
- when "kb", "kilobyte", "kilobytes"
24
- :kb
25
- when "gb", "gigabyte", "gigabytes"
26
- :gb
27
- when "unit", "units"
28
- :units
29
- else
30
- unit.to_sym
31
- end
17
+ when "mb", "megabyte", "megabytes"
18
+ :mb
19
+ when "kb", "kilobyte", "kilobytes"
20
+ :kb
21
+ when "gb", "gigabyte", "gigabytes"
22
+ :gb
23
+ when "unit", "units"
24
+ :units
25
+ else
26
+ unit.to_sym
27
+ end
32
28
 
33
29
  UsageCredits::Cost::Variable.new(self, unit)
34
30
  end
@@ -31,12 +31,7 @@ module UsageCredits
31
31
  protected
32
32
 
33
33
  def validate_amount!(amount)
34
- unless amount == amount.to_i
35
- raise ArgumentError, "Credit amount must be a whole number (got: #{amount})"
36
- end
37
- if amount.negative?
38
- raise ArgumentError, "Credit amount cannot be negative (got: #{amount})"
39
- end
34
+ CreditCalculator.normalize_credit_amount(amount)
40
35
  end
41
36
  end
42
37
  end
@@ -12,8 +12,9 @@ module UsageCredits
12
12
  end
13
13
 
14
14
  def calculate(params = {})
15
- total = costs.sum { |cost| cost.calculate(params) }
16
- CreditCalculator.apply_rounding(total)
15
+ # Components stay unrounded until Operation#calculate_cost applies the
16
+ # configured strategy once to the final sum.
17
+ costs.sum { |cost| cost.calculate(params) }
17
18
  end
18
19
 
19
20
  def +(other)
@@ -7,7 +7,7 @@ module UsageCredits
7
7
  attr_reader :period
8
8
 
9
9
  def initialize(amount)
10
- @amount = amount
10
+ super
11
11
  @period = nil # Will default to 1.month in CreditSubscriptionPlan
12
12
  end
13
13
 
@@ -18,7 +18,6 @@ module UsageCredits
18
18
  value.calculate(params)
19
19
  else
20
20
  validate_amount!(value)
21
- value.to_i
22
21
  end
23
22
  end
24
23
 
@@ -4,37 +4,52 @@ module UsageCredits
4
4
  module Cost
5
5
  # Variable credit cost based on units (e.g., 1 credit per MB)
6
6
  class Variable < Base
7
+ SUPPORTED_UNITS = %i[kb mb gb units].freeze
8
+
7
9
  attr_reader :unit
8
10
 
9
11
  def initialize(amount, unit)
10
12
  super(amount)
11
13
  @unit = unit.to_sym
14
+ raise ArgumentError, "Unknown unit: #{unit}" unless SUPPORTED_UNITS.include?(@unit)
12
15
  end
13
16
 
14
17
  def calculate(params = {})
18
+ # Return the raw composable cost. Operation#calculate_cost is the one
19
+ # rounding boundary, preventing nested Variable/Compound calculators
20
+ # from rounding the same business cost more than once.
15
21
  size = extract_size(params)
16
- raw_cost = amount * size
17
- CreditCalculator.apply_rounding(raw_cost)
22
+ amount * size
18
23
  end
19
24
 
20
25
  private
21
26
 
22
27
  def extract_size(params)
23
28
  case unit
24
- when :mb
29
+ when :kb, :mb, :gb
25
30
  # First check for direct MB value
26
31
  if params[:mb]
27
- CreditCalculator.apply_rounding(params[:mb].to_f)
32
+ convert_megabytes(params[:mb])
28
33
  # Then check for bytes that need conversion
29
34
  elsif params[:size]
30
- CreditCalculator.apply_rounding(params[:size].to_f / 1.megabyte)
35
+ params[:size].to_f / bytes_per_unit
31
36
  else
32
37
  0
33
38
  end
34
39
  when :units
35
- CreditCalculator.apply_rounding(params.fetch(:units, 0).to_f)
36
- else
37
- raise ArgumentError, "Unknown unit: #{unit}"
40
+ params.fetch(:units, 0)
41
+ end
42
+ end
43
+
44
+ def convert_megabytes(megabytes)
45
+ (megabytes.to_f * 1.megabyte) / bytes_per_unit
46
+ end
47
+
48
+ def bytes_per_unit
49
+ case unit
50
+ when :kb then 1.kilobyte
51
+ when :mb then 1.megabyte
52
+ when :gb then 1.gigabyte
38
53
  end
39
54
  end
40
55
  end
@@ -5,16 +5,9 @@ module UsageCredits
5
5
  class Engine < ::Rails::Engine
6
6
  isolate_namespace UsageCredits
7
7
 
8
- # Ensure our models load first
9
- config.autoload_paths << File.expand_path("../models", __dir__)
10
- config.autoload_paths << File.expand_path("../models/concerns", __dir__)
11
-
12
- # Set up autoloading paths
13
- initializer "usage_credits.autoload", before: :set_autoload_paths do |app|
14
- app.config.autoload_paths << root.join("lib")
15
- app.config.autoload_paths << root.join("lib/usage_credits/models")
16
- app.config.autoload_paths << root.join("lib/usage_credits/models/concerns")
17
- end
8
+ # All gem code is required eagerly by lib/usage_credits.rb. Adding these
9
+ # directories to the host app's autoloaders would make Zeitwerk claim
10
+ # common top-level constants such as ::Wallet and ::Operation.
18
11
 
19
12
  # Add has_credits method to ActiveRecord::Base
20
13
  initializer "usage_credits.active_record" do
@@ -30,8 +23,11 @@ module UsageCredits
30
23
  end
31
24
  end
32
25
 
33
- initializer "usage_credits.configs" do
34
- # Initialize any config settings
26
+ initializer "usage_credits.action_view" do
27
+ ActiveSupport.on_load :action_view do
28
+ require "usage_credits/helpers/credits_helper"
29
+ include UsageCredits::CreditsHelper
30
+ end
35
31
  end
36
32
  end
37
33
  end
@@ -21,6 +21,24 @@ module UsageCredits
21
21
  end
22
22
  end
23
23
 
24
+ # Normalize every configured or dynamically calculated credit cost through
25
+ # one strict boundary. Keeping parse-error translation here means fixed
26
+ # values and Proc results cannot match Wallets error-message text in
27
+ # separate places and drift apart.
28
+ def normalize_credit_amount(amount)
29
+ number = begin
30
+ Wallets::WholeNumber.parse(amount, name: "Credit amount")
31
+ rescue ArgumentError
32
+ raise ArgumentError, "Credit amount must be a whole number (got: #{amount})"
33
+ end
34
+
35
+ if number.negative?
36
+ raise ArgumentError, "Credit amount cannot be negative (got: #{amount})"
37
+ end
38
+
39
+ number
40
+ end
41
+
24
42
  # Convert a monetary amount to credits
25
43
  def money_to_credits(cents, exchange_rate)
26
44
  apply_rounding(cents * exchange_rate / 100.0)
@@ -17,20 +17,19 @@ module UsageCredits
17
17
  # Credit pack purchase button
18
18
  def credit_pack_button(pack, options = {})
19
19
  button_to options[:path] || credit_pack_purchase_path(pack),
20
- class: options[:class] || "credit-pack-button",
21
- method: :post,
22
- data: {
23
- turbo: false,
24
- pack_name: pack.name,
25
- credits: pack.credits,
26
- bonus_credits: pack.bonus_credits,
27
- price: pack.price_cents
28
- } do
20
+ class: options[:class] || "credit-pack-button",
21
+ method: :post,
22
+ data: {
23
+ turbo: false,
24
+ pack_name: pack.name,
25
+ credits: pack.credits,
26
+ bonus_credits: pack.bonus_credits,
27
+ price: pack.price_cents
28
+ } do
29
29
  render_credit_pack_button_content(pack)
30
30
  end
31
31
  end
32
32
 
33
-
34
33
  private
35
34
 
36
35
  def render_credit_pack_button_content(pack)
@@ -40,6 +39,5 @@ module UsageCredits
40
39
  content_tag(:span, format_credit_price(pack.price_cents, pack.price_currency), class: "price")
41
40
  ].compact, " ")
42
41
  end
43
-
44
42
  end
45
43
  end
@@ -4,7 +4,6 @@ module UsageCredits
4
4
  # Handles parsing and normalization of time periods throughout the gem.
5
5
  # Converts strings like "1.month" or symbols like :monthly into ActiveSupport::Duration objects.
6
6
  module PeriodParser
7
-
8
7
  # Canonical periods and their aliases
9
8
  VALID_PERIODS = {
10
9
  second: [:second, :seconds], # 1.second
@@ -17,6 +16,7 @@ module UsageCredits
17
16
  year: [:year, :yearly, :annually] # 1.year
18
17
  }.freeze
19
18
 
19
+ ABSOLUTE_MIN_PERIOD = 1.second
20
20
  MIN_PERIOD = 1.day # Deprecated: Use UsageCredits.configuration.min_fulfillment_period instead
21
21
 
22
22
  module_function
@@ -37,8 +37,7 @@ module UsageCredits
37
37
 
38
38
  # Handle ActiveSupport::Duration objects directly
39
39
  if period.is_a?(ActiveSupport::Duration)
40
- min_period = min_fulfillment_period
41
- raise ArgumentError, "Period must be at least #{min_period.inspect}" if period < min_period
40
+ validate_minimum!(period)
42
41
  period
43
42
  else
44
43
  # Convert symbols to canonical durations
@@ -55,8 +54,7 @@ module UsageCredits
55
54
  raise ArgumentError, "Unsupported period: #{period}. Supported periods: #{VALID_PERIODS.values.flatten.inspect}"
56
55
  end
57
56
 
58
- min_period = min_fulfillment_period
59
- raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
57
+ validate_minimum!(duration)
60
58
  duration
61
59
  end
62
60
  end
@@ -65,8 +63,11 @@ module UsageCredits
65
63
  # @param period_str [String, ActiveSupport::Duration] A string like "1.month" or "1 month" or an existing duration
66
64
  # @return [ActiveSupport::Duration] The parsed duration
67
65
  # @raise [ArgumentError] If the period string is invalid
68
- def parse_period(period_str)
69
- return period_str if period_str.is_a?(ActiveSupport::Duration)
66
+ def parse_period(period_str, enforce_minimum: true)
67
+ if period_str.is_a?(ActiveSupport::Duration)
68
+ validate_minimum!(period_str, persisted: !enforce_minimum)
69
+ return period_str
70
+ end
70
71
 
71
72
  if period_str.to_s =~ /\A(\d+)[.\s](\w+)\z/
72
73
  amount = $1.to_i
@@ -82,14 +83,19 @@ module UsageCredits
82
83
  canonical_unit = canonical_unit_for(unit)
83
84
 
84
85
  duration = amount.send(canonical_unit)
85
- min_period = min_fulfillment_period
86
- raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
86
+ validate_minimum!(duration, persisted: !enforce_minimum)
87
87
  duration
88
88
  else
89
89
  raise ArgumentError, "Invalid period format: #{period_str}. Expected format: '1.month', '2 months', etc."
90
90
  end
91
91
  end
92
92
 
93
+ # Persisted commercial schedules remain valid if an operator later raises
94
+ # the minimum allowed for newly configured plans.
95
+ def parse_persisted_period(period_str)
96
+ parse_period(period_str, enforce_minimum: false)
97
+ end
98
+
93
99
  # Map any alias to its canonical unit method name
94
100
  # @param unit [Symbol] The unit symbol (e.g., :hourly, :seconds, :day)
95
101
  # @return [Symbol] The canonical unit method (e.g., :hour, :second, :day)
@@ -111,5 +117,21 @@ module UsageCredits
111
117
  false
112
118
  end
113
119
 
120
+ def valid_persisted_period_format?(period_str)
121
+ parse_persisted_period(period_str)
122
+ true
123
+ rescue ArgumentError
124
+ false
125
+ end
126
+
127
+ def validate_minimum!(duration, persisted: false)
128
+ # Persisted commercial terms must survive a later operator-configured
129
+ # minimum increase, but they can never bypass the gem's hard safety
130
+ # floor. A zero cadence remains perpetually due and can mint on every job
131
+ # run, so one second is the absolute minimum for all schedules.
132
+ min_period = persisted ? ABSOLUTE_MIN_PERIOD : min_fulfillment_period
133
+ raise ArgumentError, "Period must be at least #{min_period.inspect}" if duration < min_period
134
+ end
135
+ private_class_method :validate_minimum!
114
136
  end
115
137
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # Normalizes and validates metadata before it crosses a payment-processor
5
+ # boundary. Stripe stores metadata as at most 50 string key/value pairs;
6
+ # structured Ruby values are encoded as one JSON string instead of becoming
7
+ # unsupported nested form parameters.
8
+ module ProcessorMetadata
9
+ MAX_PAIRS = 50
10
+ MAX_KEY_LENGTH = 40
11
+ MAX_VALUE_LENGTH = 500
12
+
13
+ module_function
14
+
15
+ def normalize(value)
16
+ hash = value.nil? ? {} : value.to_h
17
+ raise ArgumentError, "Processor metadata cannot contain more than #{MAX_PAIRS} keys" if hash.size > MAX_PAIRS
18
+
19
+ hash.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |(key, raw_value), normalized|
20
+ normalized_key = key.to_s
21
+ validate_key!(normalized_key)
22
+
23
+ normalized_value = structured?(raw_value) ? ActiveSupport::JSON.encode(raw_value) : raw_value.to_s
24
+ if normalized_value.length > MAX_VALUE_LENGTH
25
+ raise ArgumentError, "Processor metadata value for #{normalized_key.inspect} exceeds #{MAX_VALUE_LENGTH} characters"
26
+ end
27
+
28
+ normalized[normalized_key] = normalized_value
29
+ end
30
+ rescue NoMethodError, TypeError
31
+ raise ArgumentError, "Processor metadata must be hash-like"
32
+ end
33
+
34
+ def validate_key!(key)
35
+ if key.empty? || key.length > MAX_KEY_LENGTH || key.match?(/[\[\]]/)
36
+ raise ArgumentError, "Invalid processor metadata key: #{key.inspect}"
37
+ end
38
+ end
39
+ private_class_method :validate_key!
40
+
41
+ def structured?(value)
42
+ value.is_a?(Hash) || value.is_a?(Array)
43
+ end
44
+ private_class_method :structured?
45
+ end
46
+ end
@@ -17,7 +17,7 @@ module UsageCredits
17
17
  end
18
18
 
19
19
  Rails.logger.info "Completed processing #{count} fulfillments in #{formatted_time}"
20
- rescue StandardError => e
20
+ rescue => e
21
21
  Rails.logger.error "Error processing credit fulfillments: #{e.message}"
22
22
  raise # Re-raise to trigger job retry
23
23
  end
@@ -5,29 +5,24 @@ module UsageCredits
5
5
  # to a *positive* (credit) transaction, indicating how many
6
6
  # credits were taken from that specific credit source.
7
7
  #
8
- # Allocations are the basis for the bucket-based, FIFO-with-expiration inventory-like system
9
- # This is critical for calculating balances when there are mixed expiring and non-expiring credits
10
- # Otherwise, balance calculations will always be wrong because negative transactions get dragged forever
11
- # More info: https://x.com/rameerez/status/1884246492837302759
12
- class Allocation < ApplicationRecord
13
- self.table_name = "usage_credits_allocations"
8
+ # This class extends Wallets::Allocation with usage_credits table configuration.
14
9
 
15
- belongs_to :spend_transaction, class_name: "UsageCredits::Transaction", foreign_key: "transaction_id"
16
- belongs_to :source_transaction, class_name: "UsageCredits::Transaction"
10
+ class Allocation < Wallets::AllocationBase
11
+ # =========================================
12
+ # Embeddability Configuration
13
+ # =========================================
17
14
 
18
- validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
15
+ self.embedded_table_name = "usage_credits_allocations"
16
+ self.config_provider = -> { UsageCredits.configuration }
19
17
 
20
- validate :allocation_does_not_exceed_remaining_amount
21
-
22
- private
23
-
24
- def allocation_does_not_exceed_remaining_amount
25
- return if amount.blank? || source_transaction.blank?
26
-
27
- if source_transaction.remaining_amount < amount
28
- errors.add(:amount, "exceeds the remaining amount of the source transaction")
29
- end
30
- end
18
+ # =========================================
19
+ # Re-declare Associations with Correct Classes
20
+ # =========================================
31
21
 
22
+ belongs_to :spend_transaction,
23
+ class_name: "UsageCredits::Transaction",
24
+ foreign_key: "transaction_id",
25
+ optional: false
26
+ belongs_to :source_transaction, class_name: "UsageCredits::Transaction", optional: false
32
27
  end
33
28
  end