usage_credits 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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +8 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +559 -0
  6. data/Rakefile +32 -0
  7. data/lib/generators/usage_credits/install_generator.rb +49 -0
  8. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
  9. data/lib/generators/usage_credits/templates/initializer.rb +105 -0
  10. data/lib/usage_credits/configuration.rb +204 -0
  11. data/lib/usage_credits/core_ext/numeric.rb +59 -0
  12. data/lib/usage_credits/cost/base.rb +43 -0
  13. data/lib/usage_credits/cost/compound.rb +37 -0
  14. data/lib/usage_credits/cost/fixed.rb +34 -0
  15. data/lib/usage_credits/cost/variable.rb +42 -0
  16. data/lib/usage_credits/engine.rb +37 -0
  17. data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
  18. data/lib/usage_credits/helpers/credits_helper.rb +45 -0
  19. data/lib/usage_credits/helpers/period_parser.rb +77 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
  21. data/lib/usage_credits/models/allocation.rb +31 -0
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
  25. data/lib/usage_credits/models/credit_pack.rb +159 -0
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
  27. data/lib/usage_credits/models/fulfillment.rb +91 -0
  28. data/lib/usage_credits/models/operation.rb +153 -0
  29. data/lib/usage_credits/models/transaction.rb +174 -0
  30. data/lib/usage_credits/models/wallet.rb +310 -0
  31. data/lib/usage_credits/railtie.rb +17 -0
  32. data/lib/usage_credits/services/fulfillment_service.rb +129 -0
  33. data/lib/usage_credits/version.rb +5 -0
  34. data/lib/usage_credits.rb +170 -0
  35. data/sig/usagecredits.rbs +4 -0
  36. metadata +115 -0
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # A Wallet manages credit balance and transactions for a user/owner.
5
+ #
6
+ # It's responsible for:
7
+ # 1. Tracking credit balance
8
+ # 2. Performing credit operations (add/deduct)
9
+ # 3. Managing credit expiration
10
+ # 4. Handling low balance alerts
11
+
12
+ class Wallet < ApplicationRecord
13
+ self.table_name = "usage_credits_wallets"
14
+
15
+ # =========================================
16
+ # Associations & Validations
17
+ # =========================================
18
+
19
+ belongs_to :owner, polymorphic: true
20
+ has_many :transactions, class_name: "UsageCredits::Transaction", dependent: :destroy
21
+ has_many :fulfillments, class_name: "UsageCredits::Fulfillment", dependent: :destroy
22
+
23
+ validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance?
24
+
25
+ # =========================================
26
+ # Credit Balance & History
27
+ # =========================================
28
+
29
+ # Get current credit balance
30
+ #
31
+ # The first naive approach was to compute this as a sum of all non-expired transactions like:
32
+ # transactions.not_expired.sum(:amount)
33
+ # but that fails when we mix expiring and non-expiring credits: https://x.com/rameerez/status/1884246492837302759
34
+ #
35
+ # So we needed to introduce the Allocation model
36
+ #
37
+ # Now to calculate current balance, instead of summing:
38
+ # we sum only unexpired positive transactions’ remaining_amount
39
+ def credits
40
+ # Sum the leftover in all *positive* transactions that haven't expired
41
+ transactions
42
+ .where("amount > 0")
43
+ .where("expires_at IS NULL OR expires_at > ?", Time.current)
44
+ .sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM usage_credits_allocations WHERE source_transaction_id = usage_credits_transactions.id)")
45
+ .yield_self { |sum| [sum, 0].max }.to_i
46
+ end
47
+
48
+ # Get transaction history (oldest first)
49
+ def credit_history
50
+ transactions.order(created_at: :asc)
51
+ end
52
+
53
+ # =========================================
54
+ # Credit Operations
55
+ # =========================================
56
+
57
+ # Check if wallet has enough credits for an operation
58
+ def has_enough_credits_to?(operation_name, **params)
59
+ operation = find_and_validate_operation(operation_name, params)
60
+
61
+ # Then check if we actually have enough credits
62
+ credits >= operation.calculate_cost(params)
63
+ rescue InvalidOperation => e
64
+ raise e
65
+ rescue StandardError => e
66
+ raise InvalidOperation, "Error checking credits: #{e.message}"
67
+ end
68
+
69
+ # Calculate how many credits an operation would cost
70
+ def estimate_credits_to(operation_name, **params)
71
+ operation = find_and_validate_operation(operation_name, params)
72
+
73
+ # Then calculate the cost
74
+ operation.calculate_cost(params)
75
+ rescue InvalidOperation => e
76
+ raise e
77
+ rescue StandardError => e
78
+ raise InvalidOperation, "Error estimating cost: #{e.message}"
79
+ end
80
+
81
+ # Spend credits on an operation
82
+ # @param operation_name [Symbol] The operation to perform
83
+ # @param params [Hash] Parameters for the operation
84
+ # @yield Optional block that must succeed before credits are deducted
85
+ def spend_credits_on(operation_name, **params)
86
+ operation = find_and_validate_operation(operation_name, params)
87
+
88
+ cost = operation.calculate_cost(params)
89
+
90
+ # Check if user has enough credits
91
+ raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})" unless has_enough_credits_to?(operation_name, **params)
92
+
93
+ # Create audit trail
94
+ audit_data = operation.to_audit_hash(params)
95
+ deduct_params = {
96
+ metadata: audit_data.merge(operation.metadata).merge(
97
+ "executed_at" => Time.current,
98
+ "gem_version" => UsageCredits::VERSION
99
+ ),
100
+ category: :operation_charge
101
+ }
102
+
103
+ if block_given?
104
+ # If block given, only deduct credits if it succeeds
105
+ ActiveRecord::Base.transaction do
106
+ lock! # Row-level lock for concurrency safety
107
+
108
+ yield # Perform the operation first
109
+
110
+ deduct_credits(cost, **deduct_params) # Deduct credits only if the block was successful
111
+ end
112
+ else
113
+ deduct_credits(cost, **deduct_params)
114
+ end
115
+ rescue StandardError => e
116
+ raise e
117
+ end
118
+
119
+ # Give credits to the wallet
120
+ # @param amount [Integer] Number of credits to give
121
+ # @param reason [String] Why credits were given (for auditing)
122
+ def give_credits(amount, reason: nil)
123
+ raise ArgumentError, "Cannot give negative credits" if amount.negative?
124
+ raise ArgumentError, "Credit amount must be a whole number" unless amount.integer?
125
+
126
+ category = case reason&.to_s
127
+ when "signup" then :signup_bonus
128
+ when "referral" then :referral_bonus
129
+ else :manual_adjustment
130
+ end
131
+
132
+ add_credits(
133
+ amount,
134
+ metadata: { reason: reason },
135
+ category: category
136
+ )
137
+ end
138
+
139
+ # =========================================
140
+ # Credit Management (Internal API)
141
+ # =========================================
142
+
143
+ # Add credits to the wallet (internal method)
144
+ def add_credits(amount, metadata: {}, category: :credit_added, expires_at: nil, fulfillment: nil)
145
+ with_lock do
146
+ amount = amount.to_i
147
+ raise ArgumentError, "Cannot add non-positive credits" if amount <= 0
148
+
149
+ previous_balance = credits
150
+
151
+ transaction = transactions.create!(
152
+ amount: amount,
153
+ category: category,
154
+ expires_at: expires_at,
155
+ metadata: metadata,
156
+ fulfillment: fulfillment
157
+ )
158
+
159
+ # Sync the wallet's `balance` column
160
+ self.balance = credits
161
+ save!
162
+
163
+ notify_balance_change(:credits_added, amount)
164
+ check_low_balance if !was_low_balance?(previous_balance) && low_balance?
165
+
166
+ # To finish, let's return the transaction that has been just created so we can reference it in parts of the code
167
+ # Useful, for example, to update the transaction's `fulfillment` reference in the subscription extension
168
+ # after the credits have been awarded and the Fulfillment object has been created, we need to store it
169
+ return transaction
170
+ end
171
+ end
172
+
173
+ # Remove credits from the wallet (Internal method)
174
+ #
175
+ # After implementing the expiring FIFO inventory-like system through the Allocation model,
176
+ # we no longer just create one -X transaction. Now we also allocate that spend across whichever
177
+ # positive transactions still have leftover.
178
+ #
179
+ # TODO: This code enumerates all unexpired positive transactions each time.
180
+ # That’s fine if usage scale is moderate. We're already indexing this.
181
+ # If performance becomes a concern, we need to create a separate model to store the partial allocations efficiently.
182
+ def deduct_credits(amount, metadata: {}, category: :credit_deducted)
183
+ with_lock do
184
+ amount = amount.to_i
185
+ raise InsufficientCredits, "Cannot deduct a non-positive amount" if amount <= 0
186
+
187
+ # Figure out how many credits are available right now
188
+ available = credits
189
+ if amount > available && !allow_negative_balance?
190
+ raise InsufficientCredits, "Insufficient credits (#{available} < #{amount})"
191
+ end
192
+
193
+ # Create the negative transaction that represents the spend
194
+ spend_tx = transactions.create!(
195
+ amount: -amount,
196
+ category: category,
197
+ metadata: metadata
198
+ ) # We'll attach allocations to it next.
199
+
200
+ # We now allocate from oldest/soonest-expiring positive transactions
201
+ remaining_to_deduct = amount
202
+
203
+ # 1) Gather all unexpired positives with leftover, order by expire time (soonest first),
204
+ # then fallback to any with no expiry (which should come last).
205
+ positive_txs = transactions
206
+ .where("amount > 0")
207
+ .where("expires_at IS NULL OR expires_at > ?", Time.current)
208
+ .order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC"))
209
+ .lock("FOR UPDATE")
210
+ .select(:id, :amount, :expires_at)
211
+ .to_a
212
+
213
+ positive_txs.each do |pt|
214
+ # Calculate leftover amount for this transaction
215
+ allocated = pt.incoming_allocations.sum(:amount)
216
+ leftover = pt.amount - allocated
217
+ next if leftover <= 0
218
+
219
+ allocate_amount = [leftover, remaining_to_deduct].min
220
+
221
+ # Create allocation
222
+ Allocation.create!(
223
+ spend_transaction: spend_tx,
224
+ source_transaction: pt,
225
+ amount: allocate_amount
226
+ )
227
+
228
+ remaining_to_deduct -= allocate_amount
229
+ break if remaining_to_deduct <= 0
230
+ end
231
+
232
+ # If anything’s still left to deduct (and we allow negative?), we just leave it unallocated
233
+ # TODO: implement this edge case; typically we'd create an unbacked negative record.
234
+ if remaining_to_deduct.positive? && allow_negative_balance?
235
+ # The spend_tx already has -amount, so effectively user goes negative
236
+ # with no “source bucket” to allocate from. That is an edge case the end user's business logic must handle.
237
+ elsif remaining_to_deduct.positive?
238
+ # We shouldn’t get here if InsufficientCredits is raised earlier, but just in case:
239
+ raise InsufficientCredits, "Not enough credit buckets to cover the deduction"
240
+ end
241
+
242
+ # Keep the `balance` column in sync
243
+ self.balance = credits
244
+ save!
245
+
246
+ # Fire your existing notifications
247
+ notify_balance_change(:credits_deducted, amount)
248
+ spend_tx
249
+ end
250
+ end
251
+
252
+
253
+ private
254
+
255
+ # =========================================
256
+ # Helper Methods
257
+ # =========================================
258
+
259
+ # Find an operation and validate its parameters
260
+ # @param name [Symbol] Operation name
261
+ # @param params [Hash] Operation parameters to validate
262
+ # @return [Operation] The validated operation
263
+ # @raise [InvalidOperation] If operation not found or validation fails
264
+ def find_and_validate_operation(name, params)
265
+ operation = UsageCredits.operations[name.to_sym]
266
+ raise InvalidOperation, "Operation not found: #{name}" unless operation
267
+ operation.validate!(params)
268
+ operation
269
+ end
270
+
271
+ def insufficient_credits?(amount)
272
+ !allow_negative_balance? && amount > credits
273
+ end
274
+
275
+ def allow_negative_balance?
276
+ UsageCredits.configuration.allow_negative_balance
277
+ end
278
+
279
+ # =========================================
280
+ # Balance Change Notifications
281
+ # =========================================
282
+
283
+ def notify_balance_change(event, amount)
284
+ UsageCredits.handle_event(
285
+ event,
286
+ wallet: self,
287
+ amount: amount,
288
+ balance: credits
289
+ )
290
+ end
291
+
292
+ def check_low_balance
293
+ return unless low_balance?
294
+ UsageCredits.handle_event(:low_balance_reached, wallet: self)
295
+ end
296
+
297
+ def low_balance?
298
+ threshold = UsageCredits.configuration.low_balance_threshold
299
+ return false if threshold.nil? || threshold.negative?
300
+ credits <= threshold
301
+ end
302
+
303
+ def was_low_balance?(previous_balance)
304
+ threshold = UsageCredits.configuration.low_balance_threshold
305
+ return false if threshold.nil? || threshold.negative?
306
+ previous_balance <= threshold
307
+ end
308
+ end
309
+
310
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ # Railtie for Rails integration
5
+ class Railtie < Rails::Railtie
6
+ railtie_name :usage_credits
7
+
8
+ # Set up action view helpers if needed
9
+ initializer "usage_credits.action_view" do
10
+ ActiveSupport.on_load :action_view do
11
+ require "usage_credits/helpers/credits_helper"
12
+ include UsageCredits::CreditsHelper
13
+ end
14
+ end
15
+
16
+ end
17
+ end
@@ -0,0 +1,129 @@
1
+ # lib/usage_credits/services/fulfillment_service.rb
2
+ module UsageCredits
3
+ class FulfillmentService
4
+ def self.process_pending_fulfillments
5
+ count = 0
6
+ failed = 0
7
+
8
+ Fulfillment.due_for_fulfillment.find_each do |fulfillment|
9
+ begin
10
+ new(fulfillment).process
11
+ count += 1
12
+ rescue StandardError => e
13
+ failed += 1
14
+ Rails.logger.error "Failed to process fulfillment #{fulfillment.id}: #{e.message}"
15
+ Rails.logger.error e.backtrace.join("\n")
16
+ next # Continue with next fulfillment
17
+ end
18
+ end
19
+
20
+ Rails.logger.info "Processed #{count} fulfillments (#{failed} failed)"
21
+ count
22
+ end
23
+
24
+ def initialize(fulfillment)
25
+ @fulfillment = fulfillment
26
+ validate_fulfillment!
27
+ end
28
+
29
+ def process
30
+ ActiveRecord::Base.transaction do
31
+ @fulfillment.lock! # row lock to avoid double awarding
32
+
33
+ # re-check if it's still due, in case time changed or another process already updated it
34
+ return unless @fulfillment.due_for_fulfillment?
35
+
36
+ credits = calculate_credits
37
+ give_credits(credits)
38
+ update_fulfillment(credits)
39
+ end
40
+ rescue UsageCredits::Error => e
41
+ Rails.logger.error "Usage credits error processing fulfillment #{@fulfillment.id}: #{e.message}"
42
+ raise
43
+ rescue StandardError => e
44
+ Rails.logger.error "Unexpected error processing fulfillment #{@fulfillment.id}: #{e.message}"
45
+ raise
46
+ end
47
+
48
+ private
49
+
50
+ def validate_fulfillment!
51
+ raise UsageCredits::Error, "No fulfillment provided" if @fulfillment.nil?
52
+ raise UsageCredits::Error, "Invalid fulfillment type" unless ["subscription", "credit_pack", "manual"].include?(@fulfillment.fulfillment_type)
53
+ raise UsageCredits::Error, "No wallet associated with fulfillment" if @fulfillment.wallet.nil?
54
+
55
+ # Validate required metadata based on type
56
+ case @fulfillment.fulfillment_type
57
+ when "subscription"
58
+ raise UsageCredits::Error, "No plan specified in metadata" unless @fulfillment.metadata["plan"].present?
59
+ when "credit_pack"
60
+ raise UsageCredits::Error, "No pack specified in metadata" unless @fulfillment.metadata["pack"].present?
61
+ else
62
+ raise UsageCredits::Error, "No credits amount specified in metadata" unless @fulfillment.metadata["credits"].present?
63
+ end
64
+ end
65
+
66
+ def give_credits(credits)
67
+ @fulfillment.wallet.add_credits(
68
+ credits,
69
+ category: fulfillment_category,
70
+ metadata: fulfillment_metadata,
71
+ expires_at: calculate_expiration, # Will be nil if rollover is enabled
72
+ fulfillment: @fulfillment
73
+ )
74
+ end
75
+
76
+ def update_fulfillment(credits)
77
+ @fulfillment.update!(
78
+ last_fulfilled_at: Time.current,
79
+ credits_last_fulfillment: credits,
80
+ next_fulfillment_at: @fulfillment.calculate_next_fulfillment
81
+ )
82
+ end
83
+
84
+ def calculate_credits
85
+ case @fulfillment.fulfillment_type
86
+ when "subscription"
87
+ @plan = UsageCredits.find_subscription_plan_by_processor_id(@fulfillment.metadata["plan"])
88
+ raise UsageCredits::InvalidOperation, "No subscription plan found for processor ID #{@fulfillment.metadata["plan"]}" unless @plan
89
+ @plan.credits_per_period
90
+ when "credit_pack"
91
+ pack = UsageCredits.find_credit_pack(@fulfillment.metadata["pack"])
92
+ raise UsageCredits::InvalidOperation, "No credit pack named #{@fulfillment.metadata["pack"]}" unless pack
93
+ pack.total_credits
94
+ else
95
+ @fulfillment.metadata["credits"].to_i
96
+ end
97
+ end
98
+
99
+ def calculate_expiration
100
+ return nil unless @fulfillment.fulfillment_type == "subscription" && @plan
101
+ return nil if @plan.rollover_enabled
102
+
103
+ @fulfillment.calculate_next_fulfillment + UsageCredits.configuration.fulfillment_grace_period
104
+ end
105
+
106
+ def fulfillment_category
107
+ case @fulfillment.fulfillment_type
108
+ when "subscription" then "subscription_credits"
109
+ when "credit_pack" then "credit_pack_purchase"
110
+ else "credit_added"
111
+ end
112
+ end
113
+
114
+ def fulfillment_metadata
115
+ base_metadata = {
116
+ last_fulfilled_at: Time.current,
117
+ reason: "fulfillment_cycle",
118
+ fulfillment_period: @fulfillment.fulfillment_period,
119
+ fulfillment_id: @fulfillment.id
120
+ }
121
+
122
+ if @fulfillment.source.is_a?(Pay::Subscription)
123
+ base_metadata[:subscription_id] = @fulfillment.source.id
124
+ end
125
+
126
+ @fulfillment.metadata.merge(base_metadata)
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UsageCredits
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # UsageCredits provides a credits system for your application.
4
+ # This is the entry point to the gem.
5
+
6
+ require "rails"
7
+ require "active_record"
8
+ require "pay"
9
+ require "active_support/all"
10
+
11
+ # Load order matters! Dependencies are loaded in this specific order:
12
+ #
13
+ # 1. Core helpers
14
+ require "usage_credits/helpers/credit_calculator" # Centralized credit rounding
15
+ require "usage_credits/helpers/period_parser" # Parse fulfillment periods like `:monthly` to `1.month`
16
+ require "usage_credits/core_ext/numeric" # Numeric extension to write `10.credits` in our DSL
17
+
18
+ # 2. Cost calculation
19
+ require "usage_credits/cost/base"
20
+ require "usage_credits/cost/fixed"
21
+ require "usage_credits/cost/variable"
22
+ require "usage_credits/cost/compound"
23
+
24
+ # 3. Model concerns (needed by models)
25
+ require "usage_credits/models/concerns/has_wallet"
26
+ require "usage_credits/models/concerns/pay_subscription_extension"
27
+ require "usage_credits/models/concerns/pay_charge_extension"
28
+
29
+ # 4. Core functionality
30
+ require "usage_credits/version"
31
+ require "usage_credits/configuration" # Single source of truth for all configuration in this gem
32
+
33
+ # 5. Shim Rails classes so requires don't break
34
+ module UsageCredits
35
+ class ApplicationRecord < ActiveRecord::Base
36
+ self.abstract_class = true
37
+ end
38
+
39
+ class ApplicationJob < ActiveJob::Base
40
+ end
41
+ end
42
+
43
+ # 6. Models (order matters for dependencies)
44
+ require "usage_credits/models/wallet"
45
+ require "usage_credits/models/transaction"
46
+ require "usage_credits/models/allocation"
47
+ require "usage_credits/models/operation"
48
+ require "usage_credits/models/fulfillment"
49
+ require "usage_credits/models/credit_pack"
50
+ require "usage_credits/models/credit_subscription_plan"
51
+
52
+ # 7. Jobs
53
+ require "usage_credits/services/fulfillment_service.rb"
54
+ require "usage_credits/jobs/fulfillment_job.rb"
55
+
56
+ # Main module that serves as the primary interface to the gem.
57
+ # Most methods here delegate to Configuration, which is the single source of truth for all config in the initializer
58
+ module UsageCredits
59
+ # Custom error classes
60
+ class Error < StandardError; end
61
+ class InsufficientCredits < Error; end
62
+ class InvalidOperation < Error; end
63
+
64
+ class << self
65
+ attr_writer :configuration
66
+
67
+ def configuration
68
+ @configuration ||= Configuration.new
69
+ end
70
+
71
+ # Configure the gem with a block (main entry point)
72
+ def configure
73
+ yield(configuration)
74
+ end
75
+
76
+ # Reset configuration to defaults (mainly for testing)
77
+ def reset!
78
+ @configuration = nil
79
+ end
80
+
81
+ # DSL methods - all delegate to configuration
82
+ # These enable things like both `UsageCredits.credit_pack` and bare `credit_pack` usage
83
+
84
+ def operation(name, &block)
85
+ configuration.operation(name, &block)
86
+ end
87
+
88
+ def operations
89
+ configuration.operations
90
+ end
91
+
92
+ def credit_pack(name, &block)
93
+ configuration.credit_pack(name, &block)
94
+ end
95
+
96
+ def credit_packs
97
+ configuration.credit_packs
98
+ end
99
+ alias_method :packs, :credit_packs
100
+
101
+ def find_credit_pack(name)
102
+ credit_packs[name.to_sym]
103
+ end
104
+ alias_method :find_pack, :find_credit_pack
105
+
106
+ def available_credit_packs
107
+ credit_packs.values.uniq
108
+ end
109
+ alias_method :available_packs, :available_credit_packs
110
+
111
+ def subscription_plan(name, &block)
112
+ configuration.subscription_plan(name, &block)
113
+ end
114
+
115
+ def credit_subscription_plans
116
+ configuration.credit_subscription_plans
117
+ end
118
+ alias_method :subscription_plans, :credit_subscription_plans
119
+ alias_method :plans, :credit_subscription_plans
120
+
121
+ def find_subscription_plan(name)
122
+ credit_subscription_plans[name.to_sym]
123
+ end
124
+ alias_method :find_plan, :find_subscription_plan
125
+
126
+ def find_subscription_plan_by_processor_id(processor_id)
127
+ configuration.find_subscription_plan_by_processor_id(processor_id)
128
+ end
129
+ alias_method :find_plan_by_id, :find_subscription_plan_by_processor_id
130
+
131
+ # Event handling for low balance notifications
132
+ def notify_low_balance(owner)
133
+ return unless configuration.low_balance_callback
134
+ configuration.low_balance_callback.call(owner)
135
+ end
136
+
137
+ def handle_event(event, **params)
138
+ case event
139
+ when :low_balance_reached
140
+ notify_low_balance(params[:wallet].owner)
141
+ end
142
+ end
143
+
144
+ end
145
+ end
146
+
147
+ # Rails integration
148
+ require "usage_credits/engine" if defined?(Rails)
149
+ require "usage_credits/railtie" if defined?(Rails)
150
+
151
+ # Make DSL methods available at top level
152
+ # This is what enables the "bare" DSL syntax in initializers. Without it, users would have to write things like
153
+ # UsageCredits.credit_pack :starter do
154
+ # instead of
155
+ # credit_pack :starter do
156
+ #
157
+ # Note: This modifies the global Kernel module, which is a powerful but invasive approach.
158
+ module Kernel
159
+ def operation(name, &block)
160
+ UsageCredits.operation(name, &block)
161
+ end
162
+
163
+ def credit_pack(name, &block)
164
+ UsageCredits.credit_pack(name, &block)
165
+ end
166
+
167
+ def subscription_plan(name, &block)
168
+ UsageCredits.subscription_plan(name, &block)
169
+ end
170
+ end
@@ -0,0 +1,4 @@
1
+ module UsageCredits
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end