usage_credits 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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