usage_credits 0.4.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 +72 -0
  3. data/README.md +246 -13
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +65 -21
  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 +43 -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 +55 -152
  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 -48
  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
@@ -9,11 +9,10 @@ module UsageCredits
9
9
  #
10
10
  # @see PayChargeExtension for the actual payment processing, credit pack fulfilling and refund handling
11
11
  class CreditPack
12
-
13
12
  attr_reader :name,
14
- :credits, :bonus_credits,
15
- :price_cents, :price_currency,
16
- :metadata
13
+ :credits, :bonus_credits,
14
+ :price_cents, :price_currency,
15
+ :metadata
17
16
 
18
17
  def initialize(name)
19
18
  @name = name
@@ -30,17 +29,17 @@ module UsageCredits
30
29
 
31
30
  # Set the base number of credits
32
31
  def gives(amount)
33
- @credits = amount.to_i
32
+ @credits = normalize_whole_number!(amount, "Credits", minimum: 1)
34
33
  end
35
34
 
36
35
  # Set bonus credits (e.g., for promotions)
37
36
  def bonus(amount)
38
- @bonus_credits = amount.to_i
37
+ @bonus_credits = normalize_whole_number!(amount, "Bonus credits", minimum: 0)
39
38
  end
40
39
 
41
40
  # Set the price in cents (e.g., 4900 for $49.00)
42
41
  def costs(cents)
43
- @price_cents = cents
42
+ @price_cents = normalize_whole_number!(cents, "Price cents", minimum: 1)
44
43
  end
45
44
  alias_method :cost, :costs
46
45
 
@@ -48,7 +47,7 @@ module UsageCredits
48
47
  def currency(currency)
49
48
  currency = currency.to_s.downcase.to_sym
50
49
  unless UsageCredits::Configuration::VALID_CURRENCIES.include?(currency)
51
- raise ArgumentError, "Invalid currency. Must be one of: #{UsageCredits::Configuration::VALID_CURRENCIES.join(', ')}"
50
+ raise ArgumentError, "Invalid currency. Must be one of: #{UsageCredits::Configuration::VALID_CURRENCIES.join(", ")}"
52
51
  end
53
52
  @price_currency = currency.to_s.upcase
54
53
  end
@@ -158,20 +157,22 @@ module UsageCredits
158
157
  raise ArgumentError, "User must have a payment processor" unless user.respond_to?(:payment_processor) && user.payment_processor
159
158
 
160
159
  # Merge custom metadata with base_metadata (base_metadata takes precedence for critical fields)
161
- custom_metadata = options.delete(:metadata) || {}
162
- merged_metadata = custom_metadata.merge(base_metadata)
160
+ custom_metadata = merge_hash_options!(options, :metadata)
161
+ merged_metadata = UsageCredits::ProcessorMetadata.normalize(custom_metadata.merge(base_metadata))
163
162
 
164
163
  # Handle payment_intent_data specially to preserve metadata
165
164
  # We dup to avoid mutating the caller's original hash
166
- custom_payment_intent_data = (options.delete(:payment_intent_data) || {}).dup
167
- custom_pi_metadata = custom_payment_intent_data.delete(:metadata) || {}
165
+ custom_payment_intent_data = merge_hash_options!(options, :payment_intent_data)
166
+ custom_pi_metadata = merge_hash_options!(custom_payment_intent_data, :metadata)
168
167
  merged_payment_intent_data = custom_payment_intent_data.merge(
169
- metadata: custom_pi_metadata.merge(base_metadata)
168
+ metadata: UsageCredits::ProcessorMetadata.normalize(custom_pi_metadata.merge(base_metadata))
170
169
  )
171
170
 
172
171
  # Remove protected parameters that could break credit fulfillment
173
172
  options.delete(:mode)
173
+ options.delete("mode")
174
174
  options.delete(:line_items)
175
+ options.delete("line_items")
175
176
 
176
177
  user.payment_processor.checkout(
177
178
  mode: "payment",
@@ -202,5 +203,25 @@ module UsageCredits
202
203
  price_currency: price_currency
203
204
  }
204
205
  end
206
+
207
+ private
208
+
209
+ def merge_hash_options!(options, key)
210
+ values = [options.delete(key.to_s), options.delete(key)].compact
211
+ values.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |value, merged|
212
+ unless value.respond_to?(:to_h)
213
+ raise ArgumentError, "#{key} must be hash-like"
214
+ end
215
+
216
+ merged.merge!(value.to_h.with_indifferent_access)
217
+ end
218
+ end
219
+
220
+ def normalize_whole_number!(amount, name, minimum:)
221
+ amount = amount.to_i if amount.is_a?(UsageCredits::Cost::Fixed)
222
+ value = Wallets::WholeNumber.parse(amount, name: name)
223
+ raise ArgumentError, "#{name} must be at least #{minimum}" if value < minimum
224
+ value
225
+ end
205
226
  end
206
227
  end
@@ -10,12 +10,12 @@ module UsageCredits
10
10
  # @see PaySubscriptionExtension for the actual credit fulfillment logic
11
11
  class CreditSubscriptionPlan
12
12
  attr_reader :name,
13
- :processor_plan_ids,
14
- :fulfillment_period, :credits_per_period,
15
- :signup_bonus_credits, :trial_credits,
16
- :rollover_enabled,
17
- :expire_credits_on_cancel, :credit_expiration_period,
18
- :metadata
13
+ :processor_plan_ids,
14
+ :fulfillment_period, :credits_per_period,
15
+ :signup_bonus_credits, :trial_credits,
16
+ :rollover_enabled,
17
+ :expire_credits_on_cancel, :credit_expiration_period,
18
+ :metadata
19
19
 
20
20
  attr_writer :fulfillment_period
21
21
 
@@ -41,23 +41,23 @@ module UsageCredits
41
41
  # Set base credits given each period
42
42
  def gives(amount)
43
43
  if amount.is_a?(UsageCredits::Cost::Fixed)
44
- @credits_per_period = amount.amount
44
+ @credits_per_period = normalize_whole_number!(amount.amount, "Credits per period", minimum: 0)
45
45
  @fulfillment_period = UsageCredits::PeriodParser.normalize_period(amount.period || 1.month)
46
46
  self
47
47
  else
48
- @credits_per_period = amount.to_i
48
+ @credits_per_period = normalize_whole_number!(amount, "Credits per period", minimum: 0)
49
49
  CreditGiver.new(self)
50
50
  end
51
51
  end
52
52
 
53
53
  # One-time signup bonus credits
54
54
  def signup_bonus(amount)
55
- @signup_bonus_credits = amount.to_i
55
+ @signup_bonus_credits = normalize_whole_number!(amount, "Signup bonus credits", minimum: 0)
56
56
  end
57
57
 
58
58
  # Credits given during trial period
59
59
  def trial_includes(amount)
60
- @trial_credits = amount.to_i
60
+ @trial_credits = normalize_whole_number!(amount, "Trial credits", minimum: 0)
61
61
  end
62
62
 
63
63
  # Configure whether unused credits roll over between periods
@@ -75,6 +75,12 @@ module UsageCredits
75
75
  # @param duration [ActiveSupport::Duration, nil] Grace period before credits expire
76
76
  # @return [void]
77
77
  def expire_after(duration)
78
+ unless duration.nil?
79
+ seconds = duration.is_a?(ActiveSupport::Duration) ? duration.value : duration
80
+ seconds = Wallets::WholeNumber.parse(seconds, name: "Credit expiration period")
81
+ raise ArgumentError, "Credit expiration period cannot be negative" if seconds.negative?
82
+ end
83
+
78
84
  @expire_credits_on_cancel = true
79
85
  @credit_expiration_period = duration
80
86
  end
@@ -170,7 +176,7 @@ module UsageCredits
170
176
  ids = plan_id_for(:stripe)
171
177
  return {} if ids.nil?
172
178
  return ids if ids.is_a?(Hash)
173
- { default: ids } # Wrap single ID in hash for consistency
179
+ {default: ids} # Wrap single ID in hash for consistency
174
180
  end
175
181
 
176
182
  # Check if this plan matches a given processor price ID
@@ -180,7 +186,7 @@ module UsageCredits
180
186
  def matches_processor_id?(processor_id)
181
187
  processor_plan_ids.values.any? do |ids|
182
188
  if ids.is_a?(Hash)
183
- ids.values.include?(processor_id)
189
+ ids.value?(processor_id)
184
190
  else
185
191
  ids == processor_id
186
192
  end
@@ -202,15 +208,18 @@ module UsageCredits
202
208
  # plan.create_checkout_session(user, success_url: "/success", cancel_url: "/cancel", period: :year)
203
209
  def create_checkout_session(user, success_url:, cancel_url:, processor: :stripe, period: nil)
204
210
  raise ArgumentError, "User must respond to payment_processor" unless user.respond_to?(:payment_processor)
211
+ raise ArgumentError, "User must have a payment processor" unless user.payment_processor
205
212
  raise ArgumentError, "No fulfillment period configured for plan: #{name}" unless fulfillment_period
206
213
 
214
+ processor = processor.to_sym
215
+
207
216
  plan_ids = plan_id_for(processor)
208
217
  raise ArgumentError, "No #{processor.to_s.titleize} plan ID configured for plan: #{name}" unless plan_ids
209
218
 
210
219
  # Determine which price ID to use
211
220
  plan_id = if plan_ids.is_a?(Hash)
212
221
  # Multi-period plan: period is required
213
- raise ArgumentError, "This plan has multiple billing periods (#{plan_ids.keys.join(', ')}). Please specify period: parameter (e.g., period: :month)" if period.nil?
222
+ raise ArgumentError, "This plan has multiple billing periods (#{plan_ids.keys.join(", ")}). Please specify period: parameter (e.g., period: :month)" if period.nil?
214
223
  plan_ids[period.to_sym] || raise(ArgumentError, "Period #{period.inspect} not found. Available periods: #{plan_ids.keys.inspect}")
215
224
  else
216
225
  # Single-price plan: use the ID directly
@@ -251,6 +260,10 @@ module UsageCredits
251
260
  end
252
261
 
253
262
  def create_stripe_checkout_session(user, plan_id, success_url, cancel_url)
263
+ processor_metadata = UsageCredits::ProcessorMetadata.normalize(
264
+ base_metadata.merge(processor_plan: plan_id)
265
+ )
266
+
254
267
  user.payment_processor.checkout(
255
268
  mode: "subscription",
256
269
  line_items: [{
@@ -259,7 +272,7 @@ module UsageCredits
259
272
  }],
260
273
  success_url: success_url,
261
274
  cancel_url: cancel_url,
262
- subscription_data: { metadata: base_metadata }
275
+ subscription_data: {metadata: processor_metadata}
263
276
  )
264
277
  end
265
278
 
@@ -278,6 +291,15 @@ module UsageCredits
278
291
  }
279
292
  end
280
293
 
294
+ private
295
+
296
+ def normalize_whole_number!(amount, name, minimum:)
297
+ amount = amount.to_i if amount.is_a?(UsageCredits::Cost::Fixed)
298
+ value = Wallets::WholeNumber.parse(amount, name: name)
299
+ raise ArgumentError, "#{name} must be at least #{minimum}" if value < minimum
300
+ value
301
+ end
302
+
281
303
  # =========================================
282
304
  # DSL Helper Classes
283
305
  # =========================================
@@ -300,6 +322,5 @@ module UsageCredits
300
322
  @plan
301
323
  end
302
324
  end
303
-
304
325
  end
305
326
  end
@@ -5,51 +5,38 @@ module UsageCredits
5
5
  # including credit pack purchases and subscriptions.
6
6
  # Some of this credit-giving actions are repeating in nature (i.e.: subscriptions), some are not (one-time purchases)
7
7
  class Fulfillment < ApplicationRecord
8
+ include Wallets::HasMetadata
9
+
8
10
  self.table_name = "usage_credits_fulfillments"
9
11
 
10
12
  belongs_to :wallet
11
13
  belongs_to :source, polymorphic: true, optional: true
12
14
 
13
15
  validates :wallet, presence: true
14
- validates :source_id, uniqueness: { scope: :source_type }, if: :source_id?
15
- validates :credits_last_fulfillment, presence: true, numericality: { only_integer: true }
16
- validates :fulfillment_type, presence: true
16
+ validates :source_id, uniqueness: {scope: :source_type}, if: :source_id?
17
+ validates :source_type, presence: true, if: :source_id?
18
+ validates :source_id, presence: true, if: :source_type?
19
+ validates :credits_last_fulfillment,
20
+ presence: true,
21
+ numericality: {only_integer: true, greater_than_or_equal_to: 0}
22
+ validates :fulfillment_type,
23
+ presence: true,
24
+ inclusion: {in: %w[subscription credit_pack manual]}
17
25
  validate :valid_fulfillment_period_format, if: :fulfillment_period?
18
- validates :next_fulfillment_at, comparison: { greater_than: :last_fulfilled_at },
26
+ validates :next_fulfillment_at, comparison: {greater_than: :last_fulfilled_at},
19
27
  if: -> { recurring? && last_fulfilled_at.present? && next_fulfillment_at.present? }
20
28
 
21
- # =========================================
22
- # Metadata Handling
23
- # =========================================
24
-
25
- # Sync in-place modifications to metadata before saving
26
- before_save :sync_metadata_cache
27
-
28
- # Get metadata with indifferent access (string/symbol keys)
29
- # Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
30
- def metadata
31
- @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
32
- end
33
-
34
- # Set metadata, ensuring consistent storage format
35
- def metadata=(hash)
36
- @indifferent_metadata = nil # Clear cache
37
- super(hash.is_a?(Hash) ? hash.to_h : {})
38
- end
39
-
40
- # Clear metadata cache on reload to ensure fresh data from database
41
- def reload(*)
42
- @indifferent_metadata = nil
43
- super
44
- end
45
-
46
29
  # Only get fulfillments that are due AND not stopped
47
30
  scope :due_for_fulfillment, -> {
48
- where("next_fulfillment_at <= ?", Time.current)
49
- .where("stops_at IS NULL OR stops_at > ?", Time.current)
50
- .where("last_fulfilled_at IS NULL OR next_fulfillment_at > last_fulfilled_at")
31
+ table = arel_table
32
+ where(table[:next_fulfillment_at].lteq(Time.current))
33
+ .where(table[:stops_at].eq(nil).or(table[:stops_at].gt(Time.current)))
34
+ .where(table[:last_fulfilled_at].eq(nil).or(table[:next_fulfillment_at].gt(table[:last_fulfilled_at])))
35
+ }
36
+ scope :active, -> {
37
+ table = arel_table
38
+ where(table[:stops_at].eq(nil).or(table[:stops_at].gt(Time.current)))
51
39
  }
52
- scope :active, -> { where("stops_at IS NULL OR stops_at > ?", Time.current) }
53
40
 
54
41
  # Alias for backward compatibility - will be removed in next version
55
42
  scope :pending, -> { due_for_fulfillment }
@@ -83,26 +70,15 @@ module UsageCredits
83
70
  # we use current time as the base to avoid scheduling multiple rapid fulfillments.
84
71
  # This ensures smooth recovery from missed fulfillments by scheduling the next one
85
72
  # from the current time rather than the missed fulfillment time.
86
- base_time = next_fulfillment_at > Time.current ? next_fulfillment_at : Time.current
73
+ base_time = (next_fulfillment_at > Time.current) ? next_fulfillment_at : Time.current
87
74
 
88
- base_time + UsageCredits::PeriodParser.parse_period(fulfillment_period)
75
+ base_time + UsageCredits::PeriodParser.parse_persisted_period(fulfillment_period)
89
76
  end
90
77
 
91
78
  private
92
79
 
93
- # Sync in-place modifications to the cached metadata back to the attribute
94
- # This ensures changes like `metadata["key"] = "value"` are persisted on save
95
- # Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
96
- def sync_metadata_cache
97
- if @indifferent_metadata
98
- write_attribute(:metadata, @indifferent_metadata.to_h)
99
- elsif read_attribute(:metadata).nil?
100
- write_attribute(:metadata, {})
101
- end
102
- end
103
-
104
80
  def valid_fulfillment_period_format
105
- unless UsageCredits::PeriodParser.valid_period_format?(fulfillment_period)
81
+ unless UsageCredits::PeriodParser.valid_persisted_period_format?(fulfillment_period)
106
82
  errors.add(:fulfillment_period, "must be in format like '2.months' or '15.days' and use supported units")
107
83
  end
108
84
  end
@@ -122,6 +98,5 @@ module UsageCredits
122
98
  errors.add(:next_fulfillment_at, "should be nil for non-recurring fulfillments") unless new_record?
123
99
  end
124
100
  end
125
-
126
101
  end
127
102
  end
@@ -3,11 +3,10 @@
3
3
  module UsageCredits
4
4
  # A DSL to define an operation that consumes credits when performed.
5
5
  class Operation
6
-
7
6
  attr_reader :name, # Operation identifier (e.g., :process_video)
8
- :cost_calculator, # Lambda or Fixed that calculates credit cost
9
- :validation_rules, # Array of [condition, message] pairs
10
- :metadata # Custom data for your app's use
7
+ :cost_calculator, # Lambda or Fixed that calculates credit cost
8
+ :validation_rules, # Array of [condition, message] pairs
9
+ :metadata # Custom data for your app's use
11
10
 
12
11
  def initialize(name, &block)
13
12
  @name = name
@@ -61,16 +60,9 @@ module UsageCredits
61
60
  normalized_params = normalize_params(params)
62
61
  validate!(normalized_params) # Ensure params are valid before calculating
63
62
 
64
- # Calculate raw cost
65
- total = case cost_calculator
66
- when Proc
67
- result = cost_calculator.call(normalized_params)
68
- raise ArgumentError, "Credit amount must be a whole number (got: #{result})" unless result == result.to_i
69
- raise ArgumentError, "Credit amount cannot be negative (got: #{result})" if result.negative?
70
- result
71
- else
72
- cost_calculator.calculate(normalized_params)
73
- end
63
+ # `costs` wraps every configured value (including a Proc) in a Cost
64
+ # object, so there is one calculation/validation path here.
65
+ total = cost_calculator.calculate(normalized_params)
74
66
 
75
67
  # Apply configured rounding strategy
76
68
  CreditCalculator.apply_rounding(total)
@@ -92,7 +84,7 @@ module UsageCredits
92
84
  begin
93
85
  result = condition.call(normalized)
94
86
  raise InvalidOperation, message unless result
95
- rescue StandardError => e
87
+ rescue => e
96
88
  raise InvalidOperation, "Validation error: #{e.message}"
97
89
  end
98
90
  end
@@ -103,10 +95,12 @@ module UsageCredits
103
95
  # =========================================
104
96
 
105
97
  # Create an audit record of this operation
106
- def to_audit_hash(params = {})
98
+ def to_audit_hash(params = nil, cost: nil, **keyword_params)
99
+ params = (params || {}).merge(keyword_params)
100
+
107
101
  {
108
102
  operation: name,
109
- cost: calculate_cost(params),
103
+ cost: cost.nil? ? calculate_cost(params) : cost,
110
104
  params: params,
111
105
  metadata: metadata,
112
106
  executed_at: Time.current,
@@ -128,19 +122,23 @@ module UsageCredits
128
122
 
129
123
  # Handle different size specifications
130
124
  size = if params[:mb]
131
- params[:mb].to_f
132
- elsif params[:size_mb]
133
- params[:size_mb].to_f
134
- elsif params[:size_megabytes]
135
- params[:size_megabytes].to_f
136
- elsif params[:size]
137
- params[:size].to_f / 1.megabyte
138
- else
139
- 0.0
140
- end
125
+ normalize_quantity(params[:mb], :mb)
126
+ elsif params[:kb]
127
+ normalize_quantity(params[:kb], :kb) / 1024.0
128
+ elsif params[:gb]
129
+ normalize_quantity(params[:gb], :gb) * 1024.0
130
+ elsif params[:size_mb]
131
+ normalize_quantity(params[:size_mb], :size_mb)
132
+ elsif params[:size_megabytes]
133
+ normalize_quantity(params[:size_megabytes], :size_megabytes)
134
+ elsif params[:size]
135
+ normalize_quantity(params[:size], :size) / 1.megabyte
136
+ else
137
+ 0.0
138
+ end
141
139
 
142
140
  # Handle generic unit-based operations
143
- units = params[:units].to_f if params[:units]
141
+ units = normalize_quantity(params[:units], :units) if params[:units]
144
142
 
145
143
  params.merge(
146
144
  size: (size * 1.megabyte).to_i, # Raw bytes
@@ -149,5 +147,15 @@ module UsageCredits
149
147
  )
150
148
  end
151
149
 
150
+ def normalize_quantity(value, name)
151
+ number = Float(value)
152
+ unless number.finite? && !number.negative?
153
+ raise ArgumentError, "#{name} must be a finite, non-negative number"
154
+ end
155
+
156
+ number
157
+ rescue ArgumentError, TypeError
158
+ raise ArgumentError, "#{name} must be a finite, non-negative number"
159
+ end
152
160
  end
153
161
  end