usage_credits 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +246 -13
- data/lib/generators/usage_credits/install_generator.rb +9 -1
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +65 -21
- data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
- data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
- data/lib/usage_credits/callbacks.rb +9 -54
- data/lib/usage_credits/configuration.rb +43 -13
- data/lib/usage_credits/core_ext/numeric.rb +12 -16
- data/lib/usage_credits/cost/base.rb +1 -6
- data/lib/usage_credits/cost/compound.rb +3 -2
- data/lib/usage_credits/cost/fixed.rb +1 -2
- data/lib/usage_credits/cost/variable.rb +23 -8
- data/lib/usage_credits/engine.rb +8 -12
- data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
- data/lib/usage_credits/helpers/credits_helper.rb +9 -11
- data/lib/usage_credits/helpers/period_parser.rb +31 -9
- data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
- data/lib/usage_credits/models/allocation.rb +15 -20
- data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
- data/lib/usage_credits/models/credit_pack.rb +34 -13
- data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
- data/lib/usage_credits/models/fulfillment.rb +23 -48
- data/lib/usage_credits/models/operation.rb +36 -28
- data/lib/usage_credits/models/transaction.rb +55 -152
- data/lib/usage_credits/models/transfer.rb +37 -0
- data/lib/usage_credits/models/wallet.rb +219 -314
- data/lib/usage_credits/railtie.rb +4 -13
- data/lib/usage_credits/services/fulfillment_service.rb +146 -22
- data/lib/usage_credits/subscription_terms.rb +131 -0
- data/lib/usage_credits/version.rb +1 -1
- data/lib/usage_credits.rb +16 -3
- metadata +33 -20
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -48
- data/AGENTS.md +0 -5
- data/Appraisals +0 -43
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -32
- data/gemfiles/pay_10.0.gemfile +0 -41
- data/gemfiles/pay_11.0.gemfile +0 -41
- data/gemfiles/pay_8.3.gemfile +0 -41
- data/gemfiles/pay_9.0.gemfile +0 -41
- data/gemfiles/rails_7.2.gemfile +0 -41
- data/gemfiles/rails_8.1.gemfile +0 -41
|
@@ -6,15 +6,12 @@ module UsageCredits
|
|
|
6
6
|
failed = 0
|
|
7
7
|
|
|
8
8
|
Fulfillment.due_for_fulfillment.find_each do |fulfillment|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Rails.logger.error e.backtrace.join("\n")
|
|
16
|
-
next # Continue with next fulfillment
|
|
17
|
-
end
|
|
9
|
+
count += 1 if new(fulfillment).process
|
|
10
|
+
rescue => e
|
|
11
|
+
failed += 1
|
|
12
|
+
Rails.logger.error "Failed to process fulfillment #{fulfillment.id}: #{e.message}"
|
|
13
|
+
Rails.logger.error e.backtrace.join("\n")
|
|
14
|
+
next # Continue with next fulfillment
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
Rails.logger.info "Processed #{count} fulfillments (#{failed} failed)"
|
|
@@ -27,26 +24,111 @@ module UsageCredits
|
|
|
27
24
|
end
|
|
28
25
|
|
|
29
26
|
def process
|
|
30
|
-
|
|
27
|
+
credit_transaction = nil
|
|
28
|
+
|
|
29
|
+
reconcile_subscription_source!
|
|
30
|
+
|
|
31
|
+
@fulfillment.class.transaction do
|
|
32
|
+
lock_subscription_source!
|
|
31
33
|
@fulfillment.lock! # row lock to avoid double awarding
|
|
32
34
|
|
|
33
35
|
# re-check if it's still due, in case time changed or another process already updated it
|
|
34
|
-
|
|
36
|
+
next unless @fulfillment.due_for_fulfillment?
|
|
37
|
+
next unless subscription_source_eligible?
|
|
35
38
|
|
|
36
39
|
credits = calculate_credits
|
|
37
|
-
|
|
40
|
+
unless credits.is_a?(Integer) && credits.positive?
|
|
41
|
+
raise UsageCredits::Error, "Fulfillment credits must be a positive whole number"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
credit_transaction = give_credits(credits)
|
|
38
45
|
update_fulfillment(credits)
|
|
39
46
|
end
|
|
47
|
+
|
|
48
|
+
dispatch_subscription_callback(credit_transaction) if credit_transaction
|
|
49
|
+
credit_transaction
|
|
40
50
|
rescue UsageCredits::Error => e
|
|
41
51
|
Rails.logger.error "Usage credits error processing fulfillment #{@fulfillment.id}: #{e.message}"
|
|
42
52
|
raise
|
|
43
|
-
rescue
|
|
53
|
+
rescue => e
|
|
44
54
|
Rails.logger.error "Unexpected error processing fulfillment #{@fulfillment.id}: #{e.message}"
|
|
45
55
|
raise
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
private
|
|
49
59
|
|
|
60
|
+
def reconcile_subscription_source!
|
|
61
|
+
return unless @fulfillment.fulfillment_type == "subscription"
|
|
62
|
+
return unless @fulfillment.metadata["initial_award_state"] == "trial" ||
|
|
63
|
+
@fulfillment.metadata.key?("deferred_plan_change")
|
|
64
|
+
|
|
65
|
+
source = @fulfillment.source
|
|
66
|
+
return unless source.is_a?(Pay::Subscription)
|
|
67
|
+
return unless source.eligible_for_usage_credit_fulfillment?
|
|
68
|
+
|
|
69
|
+
source.sync_usage_credit_fulfillment!
|
|
70
|
+
@fulfillment.reload
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def lock_subscription_source!
|
|
74
|
+
@pay_subscription_source = false
|
|
75
|
+
@locked_subscription_source = nil
|
|
76
|
+
return unless @fulfillment.fulfillment_type == "subscription"
|
|
77
|
+
|
|
78
|
+
# A persisted polymorphic type means this fulfillment was explicitly
|
|
79
|
+
# tied to Pay. Do not reinterpret a dangling reference as a legacy
|
|
80
|
+
# source-less/custom subscription and continue minting credits.
|
|
81
|
+
source_class = @fulfillment.source_type&.safe_constantize
|
|
82
|
+
@pay_subscription_source = source_class && source_class <= Pay::Subscription
|
|
83
|
+
source = @fulfillment.source
|
|
84
|
+
return unless @pay_subscription_source || source.is_a?(Pay::Subscription)
|
|
85
|
+
|
|
86
|
+
unless source
|
|
87
|
+
raise UsageCredits::Error,
|
|
88
|
+
"Pay subscription #{@fulfillment.source_id} for fulfillment #{@fulfillment.id} no longer exists"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# All subscription writers use subscription → fulfillment → wallet lock
|
|
92
|
+
# order. Taking the processor row first makes the status check and credit
|
|
93
|
+
# mint linearizable with cancellation/activation webhooks.
|
|
94
|
+
@pay_subscription_source = true
|
|
95
|
+
@locked_subscription_source = source.class.lock.find_by(id: source.id)
|
|
96
|
+
unless @locked_subscription_source
|
|
97
|
+
raise UsageCredits::Error,
|
|
98
|
+
"Pay subscription #{source.id} for fulfillment #{@fulfillment.id} no longer exists"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def subscription_source_eligible?
|
|
103
|
+
return true unless @fulfillment.fulfillment_type == "subscription"
|
|
104
|
+
return true unless @pay_subscription_source
|
|
105
|
+
|
|
106
|
+
# A stale/delayed job must never keep minting recurring credits while the
|
|
107
|
+
# processor subscription is trialing, canceled, paused, or incomplete.
|
|
108
|
+
# Pay's processor-specific predicate handles Stripe pauses whose raw
|
|
109
|
+
# status remains "active" until the subscription is resumed.
|
|
110
|
+
source = @locked_subscription_source
|
|
111
|
+
return false unless source&.eligible_for_usage_credit_fulfillment?
|
|
112
|
+
|
|
113
|
+
if @fulfillment.metadata.key?("deferred_plan_change")
|
|
114
|
+
raise UsageCredits::InvalidOperation,
|
|
115
|
+
"Deferred plan change for fulfillment #{@fulfillment.id} was not reconciled"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
source_plan = source.processor_plan.to_s
|
|
119
|
+
active_plan = @fulfillment.metadata["plan"].to_s
|
|
120
|
+
resolved_transition = source_plan == active_plan ||
|
|
121
|
+
@fulfillment.metadata["pending_plan_change"].to_s == source_plan ||
|
|
122
|
+
@fulfillment.metadata["stopped_plan"].to_s == source_plan
|
|
123
|
+
|
|
124
|
+
unless resolved_transition
|
|
125
|
+
raise UsageCredits::InvalidOperation,
|
|
126
|
+
"Pay subscription #{source.id} plan transition has not been reconciled for fulfillment #{@fulfillment.id}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
50
132
|
def validate_fulfillment!
|
|
51
133
|
raise UsageCredits::Error, "No fulfillment provided" if @fulfillment.nil?
|
|
52
134
|
raise UsageCredits::Error, "Invalid fulfillment type" unless ["subscription", "credit_pack", "manual"].include?(@fulfillment.fulfillment_type)
|
|
@@ -57,7 +139,8 @@ module UsageCredits
|
|
|
57
139
|
when "subscription"
|
|
58
140
|
raise UsageCredits::Error, "No plan specified in metadata" unless @fulfillment.metadata["plan"].present?
|
|
59
141
|
when "credit_pack"
|
|
60
|
-
|
|
142
|
+
pack_name = @fulfillment.metadata["pack"] || @fulfillment.metadata["pack_name"]
|
|
143
|
+
raise UsageCredits::Error, "No pack specified in metadata" unless pack_name.present?
|
|
61
144
|
else
|
|
62
145
|
raise UsageCredits::Error, "No credits amount specified in metadata" unless @fulfillment.metadata["credits"].present?
|
|
63
146
|
end
|
|
@@ -73,6 +156,26 @@ module UsageCredits
|
|
|
73
156
|
)
|
|
74
157
|
end
|
|
75
158
|
|
|
159
|
+
def dispatch_subscription_callback(transaction)
|
|
160
|
+
return unless @fulfillment.fulfillment_type == "subscription"
|
|
161
|
+
|
|
162
|
+
ActiveRecord.after_all_transactions_commit do
|
|
163
|
+
UsageCredits::Callbacks.dispatch(
|
|
164
|
+
:subscription_credits_awarded,
|
|
165
|
+
wallet: @fulfillment.wallet,
|
|
166
|
+
amount: transaction.amount,
|
|
167
|
+
transaction: transaction,
|
|
168
|
+
metadata: {
|
|
169
|
+
fulfillment: @fulfillment,
|
|
170
|
+
subscription_plan_name: @plan&.name,
|
|
171
|
+
pay_subscription: @fulfillment.source,
|
|
172
|
+
fulfillment_period: @fulfillment.fulfillment_period,
|
|
173
|
+
reason: "fulfillment_cycle"
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
76
179
|
def update_fulfillment(credits)
|
|
77
180
|
@fulfillment.update!(
|
|
78
181
|
last_fulfilled_at: Time.current,
|
|
@@ -85,24 +188,37 @@ module UsageCredits
|
|
|
85
188
|
case @fulfillment.fulfillment_type
|
|
86
189
|
when "subscription"
|
|
87
190
|
@plan = UsageCredits.find_subscription_plan_by_processor_id(@fulfillment.metadata["plan"])
|
|
88
|
-
|
|
89
|
-
@
|
|
191
|
+
snapshot_credits = @fulfillment.metadata["credits_per_period"]
|
|
192
|
+
if @fulfillment.metadata.key?("credits_per_period")
|
|
193
|
+
strict_positive_integer(snapshot_credits, "credits_per_period")
|
|
194
|
+
elsif @plan
|
|
195
|
+
@plan.credits_per_period
|
|
196
|
+
else
|
|
197
|
+
raise UsageCredits::InvalidOperation, "No subscription plan found for processor ID #{@fulfillment.metadata["plan"]} and no persisted credit snapshot"
|
|
198
|
+
end
|
|
90
199
|
when "credit_pack"
|
|
91
|
-
|
|
92
|
-
|
|
200
|
+
pack_name = @fulfillment.metadata["pack"] || @fulfillment.metadata["pack_name"]
|
|
201
|
+
pack = UsageCredits.find_credit_pack(pack_name)
|
|
202
|
+
raise UsageCredits::InvalidOperation, "No credit pack named #{pack_name}" unless pack
|
|
93
203
|
pack.total_credits
|
|
94
204
|
else
|
|
95
|
-
@fulfillment.metadata["credits"]
|
|
205
|
+
strict_positive_integer(@fulfillment.metadata["credits"], "credits")
|
|
96
206
|
end
|
|
97
207
|
end
|
|
98
208
|
|
|
99
209
|
def calculate_expiration
|
|
100
|
-
return nil unless @fulfillment.fulfillment_type == "subscription"
|
|
101
|
-
|
|
210
|
+
return nil unless @fulfillment.fulfillment_type == "subscription"
|
|
211
|
+
|
|
212
|
+
rollover = if @fulfillment.metadata.key?("rollover_enabled")
|
|
213
|
+
ActiveModel::Type::Boolean.new.cast(@fulfillment.metadata["rollover_enabled"])
|
|
214
|
+
else
|
|
215
|
+
@plan&.rollover_enabled
|
|
216
|
+
end
|
|
217
|
+
return nil if rollover
|
|
102
218
|
|
|
103
219
|
# Cap the grace period to the fulfillment period to prevent balance accumulation
|
|
104
220
|
# when fulfillment_period << grace_period (e.g., 15 seconds vs 5 minutes)
|
|
105
|
-
fulfillment_period = @
|
|
221
|
+
fulfillment_period = UsageCredits::PeriodParser.parse_persisted_period(@fulfillment.fulfillment_period)
|
|
106
222
|
effective_grace = [
|
|
107
223
|
UsageCredits.configuration.fulfillment_grace_period,
|
|
108
224
|
fulfillment_period
|
|
@@ -111,6 +227,14 @@ module UsageCredits
|
|
|
111
227
|
@fulfillment.calculate_next_fulfillment + effective_grace
|
|
112
228
|
end
|
|
113
229
|
|
|
230
|
+
def strict_positive_integer(value, name)
|
|
231
|
+
number = Wallets::WholeNumber.parse(value, name: name, allow_string: true)
|
|
232
|
+
raise UsageCredits::Error, "#{name} must be positive" unless number.positive?
|
|
233
|
+
number
|
|
234
|
+
rescue ArgumentError
|
|
235
|
+
raise UsageCredits::Error, "#{name} must be a positive whole number"
|
|
236
|
+
end
|
|
237
|
+
|
|
114
238
|
def fulfillment_category
|
|
115
239
|
case @fulfillment.fulfillment_type
|
|
116
240
|
when "subscription" then "subscription_credits"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UsageCredits
|
|
4
|
+
# Immutable commercial terms used to fulfill one processor subscription.
|
|
5
|
+
# Terms may come from checkout metadata, an existing fulfillment snapshot,
|
|
6
|
+
# or (for legacy records) the current Ruby configuration.
|
|
7
|
+
class SubscriptionTerms
|
|
8
|
+
TRUE_VALUES = [true, 1, "1", "true"].freeze
|
|
9
|
+
FALSE_VALUES = [false, 0, "0", "false"].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :name,
|
|
12
|
+
:processor_plan_id,
|
|
13
|
+
:credits_per_period,
|
|
14
|
+
:signup_bonus_credits,
|
|
15
|
+
:trial_credits,
|
|
16
|
+
:fulfillment_period_display,
|
|
17
|
+
:rollover_enabled,
|
|
18
|
+
:expire_credits_on_cancel,
|
|
19
|
+
:credit_expiration_period_seconds,
|
|
20
|
+
:configured_plan
|
|
21
|
+
|
|
22
|
+
def self.from_plan(plan, processor_plan_id:)
|
|
23
|
+
return if plan.nil?
|
|
24
|
+
|
|
25
|
+
new(
|
|
26
|
+
name: plan.name,
|
|
27
|
+
processor_plan_id: processor_plan_id,
|
|
28
|
+
credits_per_period: plan.credits_per_period,
|
|
29
|
+
signup_bonus_credits: plan.signup_bonus_credits,
|
|
30
|
+
trial_credits: plan.trial_credits,
|
|
31
|
+
fulfillment_period: plan.fulfillment_period_display,
|
|
32
|
+
rollover_enabled: plan.rollover_enabled,
|
|
33
|
+
expire_credits_on_cancel: plan.expire_credits_on_cancel,
|
|
34
|
+
credit_expiration_period: plan.credit_expiration_period&.to_i,
|
|
35
|
+
configured_plan: plan
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.from_metadata(metadata, processor_plan_id:, configured_plan: nil)
|
|
40
|
+
data = (metadata || {}).with_indifferent_access
|
|
41
|
+
return unless data.key?(:credits_per_period) && data.key?(:fulfillment_period) && data.key?(:rollover_enabled)
|
|
42
|
+
|
|
43
|
+
new(
|
|
44
|
+
name: data[:plan_name].presence || data[:subscription_name].presence || configured_plan&.name || processor_plan_id,
|
|
45
|
+
processor_plan_id: processor_plan_id,
|
|
46
|
+
credits_per_period: data[:credits_per_period],
|
|
47
|
+
signup_bonus_credits: data.fetch(:signup_bonus_credits, 0),
|
|
48
|
+
trial_credits: data.fetch(:trial_credits, 0),
|
|
49
|
+
fulfillment_period: data[:fulfillment_period],
|
|
50
|
+
rollover_enabled: parse_boolean(data[:rollover_enabled], "rollover_enabled"),
|
|
51
|
+
expire_credits_on_cancel: boolean_from_metadata(
|
|
52
|
+
data,
|
|
53
|
+
:expire_credits_on_cancel,
|
|
54
|
+
fallback: configured_plan&.expire_credits_on_cancel || false
|
|
55
|
+
),
|
|
56
|
+
credit_expiration_period: value_from_metadata(
|
|
57
|
+
data,
|
|
58
|
+
:credit_expiration_period,
|
|
59
|
+
fallback: configured_plan&.credit_expiration_period&.to_i
|
|
60
|
+
),
|
|
61
|
+
configured_plan: configured_plan,
|
|
62
|
+
allow_string_numbers: true
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.parse_boolean(value, name)
|
|
67
|
+
return true if TRUE_VALUES.include?(value)
|
|
68
|
+
return false if FALSE_VALUES.include?(value)
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "#{name} must be true or false"
|
|
71
|
+
end
|
|
72
|
+
private_class_method :parse_boolean
|
|
73
|
+
|
|
74
|
+
def self.boolean_from_metadata(data, key, fallback:)
|
|
75
|
+
value = value_from_metadata(data, key, fallback: fallback)
|
|
76
|
+
parse_boolean(value, key)
|
|
77
|
+
end
|
|
78
|
+
private_class_method :boolean_from_metadata
|
|
79
|
+
|
|
80
|
+
def self.value_from_metadata(data, key, fallback:)
|
|
81
|
+
value = data[key]
|
|
82
|
+
(value.nil? || value == "") ? fallback : value
|
|
83
|
+
end
|
|
84
|
+
private_class_method :value_from_metadata
|
|
85
|
+
|
|
86
|
+
def initialize(name:, processor_plan_id:, credits_per_period:, signup_bonus_credits:, trial_credits:,
|
|
87
|
+
fulfillment_period:, rollover_enabled:, expire_credits_on_cancel: false, credit_expiration_period: nil,
|
|
88
|
+
configured_plan: nil, allow_string_numbers: false)
|
|
89
|
+
@name = name.respond_to?(:to_sym) ? name.to_sym : name
|
|
90
|
+
@processor_plan_id = processor_plan_id.to_s
|
|
91
|
+
@credits_per_period = parse_positive(credits_per_period, "credits_per_period", allow_string_numbers)
|
|
92
|
+
@signup_bonus_credits = parse_non_negative(signup_bonus_credits, "signup_bonus_credits", allow_string_numbers)
|
|
93
|
+
@trial_credits = parse_non_negative(trial_credits, "trial_credits", allow_string_numbers)
|
|
94
|
+
@fulfillment_period_display = fulfillment_period.to_s
|
|
95
|
+
@parsed_fulfillment_period = UsageCredits::PeriodParser.parse_persisted_period(@fulfillment_period_display)
|
|
96
|
+
@rollover_enabled = rollover_enabled == true
|
|
97
|
+
@expire_credits_on_cancel = expire_credits_on_cancel == true
|
|
98
|
+
@credit_expiration_period_seconds = parse_expiration_period(credit_expiration_period, allow_string_numbers)
|
|
99
|
+
@configured_plan = configured_plan
|
|
100
|
+
freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
attr_reader :parsed_fulfillment_period
|
|
104
|
+
|
|
105
|
+
def callback_plan
|
|
106
|
+
configured_plan || self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def parse_non_negative(value, name, allow_string)
|
|
112
|
+
number = Wallets::WholeNumber.parse(value, name: name, allow_string: allow_string)
|
|
113
|
+
raise ArgumentError, "#{name} cannot be negative" if number.negative?
|
|
114
|
+
|
|
115
|
+
number
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse_positive(value, name, allow_string)
|
|
119
|
+
number = parse_non_negative(value, name, allow_string)
|
|
120
|
+
raise ArgumentError, "#{name} must be positive" unless number.positive?
|
|
121
|
+
|
|
122
|
+
number
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_expiration_period(value, allow_string)
|
|
126
|
+
return 0 if value.nil? || value == ""
|
|
127
|
+
|
|
128
|
+
parse_non_negative(value, "credit_expiration_period", allow_string)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/usage_credits.rb
CHANGED
|
@@ -5,14 +5,25 @@
|
|
|
5
5
|
|
|
6
6
|
require "rails"
|
|
7
7
|
require "active_record"
|
|
8
|
+
require "active_job"
|
|
8
9
|
require "pay"
|
|
10
|
+
require "wallets"
|
|
9
11
|
require "active_support/all"
|
|
10
12
|
|
|
13
|
+
module UsageCredits
|
|
14
|
+
# usage_credits deliberately exposes one asset. Keep its persisted value in
|
|
15
|
+
# one runtime constant so association lookup and race-safe creation cannot
|
|
16
|
+
# drift apart.
|
|
17
|
+
DEFAULT_ASSET_CODE = "credits"
|
|
18
|
+
end
|
|
19
|
+
|
|
11
20
|
# Load order matters! Dependencies are loaded in this specific order:
|
|
12
21
|
#
|
|
13
22
|
# 1. Core helpers
|
|
14
23
|
require "usage_credits/helpers/credit_calculator" # Centralized credit rounding
|
|
15
24
|
require "usage_credits/helpers/period_parser" # Parse fulfillment periods like `:monthly` to `1.month`
|
|
25
|
+
require "usage_credits/helpers/processor_metadata" # Payment metadata limits and serialization
|
|
26
|
+
require "usage_credits/subscription_terms" # Immutable processor subscription snapshots
|
|
16
27
|
require "usage_credits/core_ext/numeric" # Numeric extension to write `10.credits` in our DSL
|
|
17
28
|
|
|
18
29
|
# 2. Cost calculation
|
|
@@ -43,17 +54,19 @@ module UsageCredits
|
|
|
43
54
|
end
|
|
44
55
|
|
|
45
56
|
# 6. Models (order matters for dependencies)
|
|
57
|
+
# These extend Wallets::* classes, so wallets gem must be loaded first
|
|
46
58
|
require "usage_credits/models/wallet"
|
|
47
59
|
require "usage_credits/models/transaction"
|
|
48
60
|
require "usage_credits/models/allocation"
|
|
61
|
+
require "usage_credits/models/transfer"
|
|
49
62
|
require "usage_credits/models/operation"
|
|
50
63
|
require "usage_credits/models/fulfillment"
|
|
51
64
|
require "usage_credits/models/credit_pack"
|
|
52
65
|
require "usage_credits/models/credit_subscription_plan"
|
|
53
66
|
|
|
54
67
|
# 7. Jobs
|
|
55
|
-
require "usage_credits/services/fulfillment_service
|
|
56
|
-
require "usage_credits/jobs/fulfillment_job
|
|
68
|
+
require "usage_credits/services/fulfillment_service"
|
|
69
|
+
require "usage_credits/jobs/fulfillment_job"
|
|
57
70
|
|
|
58
71
|
# Main module that serves as the primary interface to the gem.
|
|
59
72
|
# Most methods here delegate to Configuration, which is the single source of truth for all config in the initializer
|
|
@@ -62,6 +75,7 @@ module UsageCredits
|
|
|
62
75
|
class Error < StandardError; end
|
|
63
76
|
class InsufficientCredits < Error; end
|
|
64
77
|
class InvalidOperation < Error; end
|
|
78
|
+
class InvalidTransfer < Error; end
|
|
65
79
|
|
|
66
80
|
class << self
|
|
67
81
|
attr_writer :configuration
|
|
@@ -157,7 +171,6 @@ module UsageCredits
|
|
|
157
171
|
notify_low_balance(params[:wallet]&.owner)
|
|
158
172
|
end
|
|
159
173
|
end
|
|
160
|
-
|
|
161
174
|
end
|
|
162
175
|
end
|
|
163
176
|
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: usage_credits
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-18 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: pay
|
|
@@ -15,7 +15,7 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 11.6.2
|
|
19
19
|
- - "<"
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
21
|
version: '12.0'
|
|
@@ -25,7 +25,7 @@ dependencies:
|
|
|
25
25
|
requirements:
|
|
26
26
|
- - ">="
|
|
27
27
|
- !ruby/object:Gem::Version
|
|
28
|
-
version:
|
|
28
|
+
version: 11.6.2
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '12.0'
|
|
@@ -35,14 +35,34 @@ dependencies:
|
|
|
35
35
|
requirements:
|
|
36
36
|
- - ">="
|
|
37
37
|
- !ruby/object:Gem::Version
|
|
38
|
-
version:
|
|
38
|
+
version: 7.2.3.1
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9.0'
|
|
39
42
|
type: :runtime
|
|
40
43
|
prerelease: false
|
|
41
44
|
version_requirements: !ruby/object:Gem::Requirement
|
|
42
45
|
requirements:
|
|
43
46
|
- - ">="
|
|
44
47
|
- !ruby/object:Gem::Version
|
|
45
|
-
version:
|
|
48
|
+
version: 7.2.3.1
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9.0'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: wallets
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - "~>"
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: 0.4.0
|
|
59
|
+
type: :runtime
|
|
60
|
+
prerelease: false
|
|
61
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - "~>"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: 0.4.0
|
|
46
66
|
description: Add a usage-based credit system to your Rails app, easily. Let users
|
|
47
67
|
buy and spend credits on usage-based actions. Refill Stripe subscriptions with credits,
|
|
48
68
|
sell one-time booster credit packs, implement PAYG (pay-as-you-go) billing, award
|
|
@@ -55,24 +75,14 @@ executables: []
|
|
|
55
75
|
extensions: []
|
|
56
76
|
extra_rdoc_files: []
|
|
57
77
|
files:
|
|
58
|
-
- ".rubocop.yml"
|
|
59
|
-
- ".simplecov"
|
|
60
|
-
- AGENTS.md
|
|
61
|
-
- Appraisals
|
|
62
78
|
- CHANGELOG.md
|
|
63
|
-
- CLAUDE.md
|
|
64
79
|
- LICENSE.txt
|
|
65
80
|
- README.md
|
|
66
|
-
- Rakefile
|
|
67
|
-
- gemfiles/pay_10.0.gemfile
|
|
68
|
-
- gemfiles/pay_11.0.gemfile
|
|
69
|
-
- gemfiles/pay_8.3.gemfile
|
|
70
|
-
- gemfiles/pay_9.0.gemfile
|
|
71
|
-
- gemfiles/rails_7.2.gemfile
|
|
72
|
-
- gemfiles/rails_8.1.gemfile
|
|
73
81
|
- lib/generators/usage_credits/install_generator.rb
|
|
74
82
|
- lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb
|
|
75
83
|
- lib/generators/usage_credits/templates/initializer.rb
|
|
84
|
+
- lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb
|
|
85
|
+
- lib/generators/usage_credits/upgrade_generator.rb
|
|
76
86
|
- lib/usage_credits.rb
|
|
77
87
|
- lib/usage_credits/callback_context.rb
|
|
78
88
|
- lib/usage_credits/callbacks.rb
|
|
@@ -86,6 +96,7 @@ files:
|
|
|
86
96
|
- lib/usage_credits/helpers/credit_calculator.rb
|
|
87
97
|
- lib/usage_credits/helpers/credits_helper.rb
|
|
88
98
|
- lib/usage_credits/helpers/period_parser.rb
|
|
99
|
+
- lib/usage_credits/helpers/processor_metadata.rb
|
|
89
100
|
- lib/usage_credits/jobs/fulfillment_job.rb
|
|
90
101
|
- lib/usage_credits/models/allocation.rb
|
|
91
102
|
- lib/usage_credits/models/concerns/has_wallet.rb
|
|
@@ -96,9 +107,11 @@ files:
|
|
|
96
107
|
- lib/usage_credits/models/fulfillment.rb
|
|
97
108
|
- lib/usage_credits/models/operation.rb
|
|
98
109
|
- lib/usage_credits/models/transaction.rb
|
|
110
|
+
- lib/usage_credits/models/transfer.rb
|
|
99
111
|
- lib/usage_credits/models/wallet.rb
|
|
100
112
|
- lib/usage_credits/railtie.rb
|
|
101
113
|
- lib/usage_credits/services/fulfillment_service.rb
|
|
114
|
+
- lib/usage_credits/subscription_terms.rb
|
|
102
115
|
- lib/usage_credits/version.rb
|
|
103
116
|
- sig/usagecredits.rbs
|
|
104
117
|
homepage: https://github.com/rameerez/usage_credits
|
|
@@ -107,7 +120,7 @@ licenses:
|
|
|
107
120
|
metadata:
|
|
108
121
|
allowed_push_host: https://rubygems.org
|
|
109
122
|
homepage_uri: https://github.com/rameerez/usage_credits
|
|
110
|
-
source_code_uri: https://github.com/rameerez/usage_credits
|
|
123
|
+
source_code_uri: https://github.com/rameerez/usage_credits/tree/main
|
|
111
124
|
changelog_uri: https://github.com/rameerez/usage_credits/blob/main/CHANGELOG.md
|
|
112
125
|
rubygems_mfa_required: 'true'
|
|
113
126
|
rdoc_options: []
|
|
@@ -117,7 +130,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
117
130
|
requirements:
|
|
118
131
|
- - ">="
|
|
119
132
|
- !ruby/object:Gem::Version
|
|
120
|
-
version: 3.
|
|
133
|
+
version: 3.2.0
|
|
121
134
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
122
135
|
requirements:
|
|
123
136
|
- - ">="
|
data/.rubocop.yml
DELETED
data/.simplecov
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
SimpleCov.start 'rails' do
|
|
4
|
-
# Coverage directory
|
|
5
|
-
coverage_dir 'coverage'
|
|
6
|
-
|
|
7
|
-
# Enable branch coverage (must be before minimum_coverage)
|
|
8
|
-
enable_coverage :branch
|
|
9
|
-
|
|
10
|
-
# Set minimum coverage threshold to prevent coverage regression
|
|
11
|
-
# Current coverage: Line 84.38%, Branch 71.9%
|
|
12
|
-
# Note: Some paths (PostgreSQL JSON operators, error fallbacks) may not be exercised in SQLite tests
|
|
13
|
-
minimum_coverage line: 75, branch: 60
|
|
14
|
-
|
|
15
|
-
# Add custom groups for better organization
|
|
16
|
-
add_group 'Models', 'lib/usage_credits/models'
|
|
17
|
-
add_group 'Services', 'lib/usage_credits/services'
|
|
18
|
-
add_group 'Helpers', 'lib/usage_credits/helpers'
|
|
19
|
-
add_group 'Jobs', 'lib/usage_credits/jobs'
|
|
20
|
-
add_group 'Concerns', 'lib/usage_credits/models/concerns'
|
|
21
|
-
add_group 'DSL', ['lib/usage_credits/operation.rb', 'lib/usage_credits/credit_pack.rb', 'lib/usage_credits/credit_subscription_plan.rb']
|
|
22
|
-
|
|
23
|
-
# Filter out files we don't want to track
|
|
24
|
-
add_filter '/test/'
|
|
25
|
-
add_filter '/spec/'
|
|
26
|
-
add_filter '/config/'
|
|
27
|
-
add_filter '/db/'
|
|
28
|
-
add_filter '/vendor/'
|
|
29
|
-
add_filter '/bin/'
|
|
30
|
-
|
|
31
|
-
# Track all Ruby files in lib
|
|
32
|
-
track_files 'lib/**/*.rb'
|
|
33
|
-
|
|
34
|
-
# Disambiguate parallel test runs
|
|
35
|
-
command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
|
|
36
|
-
|
|
37
|
-
# Use different formatters for CI vs local
|
|
38
|
-
if ENV['CI']
|
|
39
|
-
# CI: Use simple formatter for console output
|
|
40
|
-
formatter SimpleCov::Formatter::SimpleFormatter
|
|
41
|
-
else
|
|
42
|
-
# Local: Use HTML formatter for detailed report
|
|
43
|
-
formatter SimpleCov::Formatter::HTMLFormatter
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Merge results from parallel runs
|
|
47
|
-
merge_timeout 3600
|
|
48
|
-
end
|
data/AGENTS.md
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
# AGENTS.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to AI Agents (like OpenAI's Codex, Claude Code, etc) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
|
data/Appraisals
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Test minimum supported Rails version (with latest Pay)
|
|
4
|
-
appraise "rails-7.2" do
|
|
5
|
-
gem "rails", "~> 7.2.0"
|
|
6
|
-
gem "pay", "~> 11.0"
|
|
7
|
-
gem "stripe", "~> 18.0"
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
# Test latest Rails version (with latest Pay) - this is the default/main Gemfile anyway
|
|
11
|
-
appraise "rails-8.1" do
|
|
12
|
-
gem "rails", "~> 8.1.0"
|
|
13
|
-
gem "pay", "~> 11.0"
|
|
14
|
-
gem "stripe", "~> 18.0"
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# Test minimum supported Pay version (with latest Rails)
|
|
18
|
-
appraise "pay-8.3" do
|
|
19
|
-
gem "pay", "~> 8.3.0"
|
|
20
|
-
gem "stripe", "~> 13.0"
|
|
21
|
-
gem "rails", "~> 8.1.0"
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# Test Pay 9.0 (popular stable version with latest Rails)
|
|
25
|
-
appraise "pay-9.0" do
|
|
26
|
-
gem "pay", "~> 9.0.0"
|
|
27
|
-
gem "stripe", "~> 13.0"
|
|
28
|
-
gem "rails", "~> 8.1.0"
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Test Pay 10.0 (with latest Rails)
|
|
32
|
-
appraise "pay-10.0" do
|
|
33
|
-
gem "pay", "~> 10.0.0"
|
|
34
|
-
gem "stripe", "~> 15.0"
|
|
35
|
-
gem "rails", "~> 8.1.0"
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Test latest Pay version (with latest Rails)
|
|
39
|
-
appraise "pay-11.0" do
|
|
40
|
-
gem "pay", "~> 11.0"
|
|
41
|
-
gem "stripe", "~> 18.0"
|
|
42
|
-
gem "rails", "~> 8.1.0"
|
|
43
|
-
end
|