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