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.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +559 -0
- data/Rakefile +32 -0
- data/lib/generators/usage_credits/install_generator.rb +49 -0
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
- data/lib/generators/usage_credits/templates/initializer.rb +105 -0
- data/lib/usage_credits/configuration.rb +204 -0
- data/lib/usage_credits/core_ext/numeric.rb +59 -0
- data/lib/usage_credits/cost/base.rb +43 -0
- data/lib/usage_credits/cost/compound.rb +37 -0
- data/lib/usage_credits/cost/fixed.rb +34 -0
- data/lib/usage_credits/cost/variable.rb +42 -0
- data/lib/usage_credits/engine.rb +37 -0
- data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
- data/lib/usage_credits/helpers/credits_helper.rb +45 -0
- data/lib/usage_credits/helpers/period_parser.rb +77 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
- data/lib/usage_credits/models/allocation.rb +31 -0
- data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
- data/lib/usage_credits/models/credit_pack.rb +159 -0
- data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
- data/lib/usage_credits/models/fulfillment.rb +91 -0
- data/lib/usage_credits/models/operation.rb +153 -0
- data/lib/usage_credits/models/transaction.rb +174 -0
- data/lib/usage_credits/models/wallet.rb +310 -0
- data/lib/usage_credits/railtie.rb +17 -0
- data/lib/usage_credits/services/fulfillment_service.rb +129 -0
- data/lib/usage_credits/version.rb +5 -0
- data/lib/usage_credits.rb +170 -0
- data/sig/usagecredits.rbs +4 -0
- 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,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
|