usage_credits 0.1.1 → 0.2.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.
@@ -26,6 +26,7 @@ module UsageCredits
26
26
  "subscription_credits", # Generic subscription credits
27
27
  "subscription_trial", # Trial period credits
28
28
  "subscription_signup_bonus", # Bonus for subscribing
29
+ "subscription_upgrade", # Plan upgrade credits
29
30
 
30
31
  # One-time purchases
31
32
  "credit_pack", # Generic credit pack
@@ -94,9 +94,10 @@ module UsageCredits
94
94
  raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})" unless has_enough_credits_to?(operation_name, **params)
95
95
 
96
96
  # Create audit trail
97
- audit_data = operation.to_audit_hash(params)
97
+ # Stringify keys from audit_data to avoid duplicate key warnings in JSON
98
+ audit_data = operation.to_audit_hash(params).deep_stringify_keys
98
99
  deduct_params = {
99
- metadata: audit_data.merge(operation.metadata).merge(
100
+ metadata: audit_data.merge(operation.metadata.deep_stringify_keys).merge(
100
101
  "executed_at" => Time.current,
101
102
  "gem_version" => UsageCredits::VERSION
102
103
  ),
@@ -126,7 +127,7 @@ module UsageCredits
126
127
  def give_credits(amount, reason: nil, expires_at: nil)
127
128
  raise ArgumentError, "Amount is required" if amount.nil?
128
129
  raise ArgumentError, "Cannot give negative credits" if amount.to_i.negative?
129
- raise ArgumentError, "Credit amount must be a whole number" unless amount.to_i.integer?
130
+ raise ArgumentError, "Credit amount must be a whole number" unless amount == amount.to_i
130
131
  raise ArgumentError, "Expiration date must be a valid datetime" if expires_at && !expires_at.respond_to?(:to_datetime)
131
132
  raise ArgumentError, "Expiration date must be in the future" if expires_at && expires_at <= Time.current
132
133
 
@@ -155,8 +156,6 @@ module UsageCredits
155
156
  amount = amount.to_i
156
157
  raise ArgumentError, "Cannot add non-positive credits" if amount <= 0
157
158
 
158
- previous_balance = credits
159
-
160
159
  transaction = transactions.create!(
161
160
  amount: amount,
162
161
  category: category,
@@ -170,7 +169,6 @@ module UsageCredits
170
169
  save!
171
170
 
172
171
  notify_balance_change(:credits_added, amount)
173
- check_low_balance if !was_low_balance?(previous_balance) && low_balance?
174
172
 
175
173
  # To finish, let's return the transaction that has been just created so we can reference it in parts of the code
176
174
  # Useful, for example, to update the transaction's `fulfillment` reference in the subscription extension
@@ -186,15 +184,18 @@ module UsageCredits
186
184
  # positive transactions still have leftover.
187
185
  #
188
186
  # TODO: This code enumerates all unexpired positive transactions each time.
189
- # Thats fine if usage scale is moderate. We're already indexing this.
187
+ # That's fine if usage scale is moderate. We're already indexing this.
190
188
  # If performance becomes a concern, we need to create a separate model to store the partial allocations efficiently.
191
189
  def deduct_credits(amount, metadata: {}, category: :credit_deducted)
192
- with_lock do
190
+ with_lock do
193
191
  amount = amount.to_i
194
192
  raise InsufficientCredits, "Cannot deduct a non-positive amount" if amount <= 0
195
193
 
194
+ # Capture previous balance for low_balance check
195
+ previous_balance = credits
196
+
196
197
  # Figure out how many credits are available right now
197
- available = credits
198
+ available = previous_balance
198
199
  if amount > available && !allow_negative_balance?
199
200
  raise InsufficientCredits, "Insufficient credits (#{available} < #{amount})"
200
201
  end
@@ -254,9 +255,13 @@ module UsageCredits
254
255
 
255
256
  # Fire your existing notifications
256
257
  notify_balance_change(:credits_deducted, amount)
258
+
259
+ # Check if we crossed the low balance threshold
260
+ check_low_balance if !was_low_balance?(previous_balance) && low_balance?
261
+
257
262
  spend_tx
263
+ end
258
264
  end
259
- end
260
265
 
261
266
 
262
267
  private
@@ -100,7 +100,15 @@ module UsageCredits
100
100
  return nil unless @fulfillment.fulfillment_type == "subscription" && @plan
101
101
  return nil if @plan.rollover_enabled
102
102
 
103
- @fulfillment.calculate_next_fulfillment + UsageCredits.configuration.fulfillment_grace_period
103
+ # Cap the grace period to the fulfillment period to prevent balance accumulation
104
+ # when fulfillment_period << grace_period (e.g., 15 seconds vs 5 minutes)
105
+ fulfillment_period = @plan.parsed_fulfillment_period
106
+ effective_grace = [
107
+ UsageCredits.configuration.fulfillment_grace_period,
108
+ fulfillment_period
109
+ ].min
110
+
111
+ @fulfillment.calculate_next_fulfillment + effective_grace
104
112
  end
105
113
 
106
114
  def fulfillment_category
@@ -112,15 +120,16 @@ module UsageCredits
112
120
  end
113
121
 
114
122
  def fulfillment_metadata
123
+ # Use string keys consistently to avoid duplicates after JSON serialization
115
124
  base_metadata = {
116
- last_fulfilled_at: Time.current,
117
- reason: "fulfillment_cycle",
118
- fulfillment_period: @fulfillment.fulfillment_period,
119
- fulfillment_id: @fulfillment.id
125
+ "last_fulfilled_at" => Time.current,
126
+ "reason" => "fulfillment_cycle",
127
+ "fulfillment_period" => @fulfillment.fulfillment_period,
128
+ "fulfillment_id" => @fulfillment.id
120
129
  }
121
130
 
122
131
  if @fulfillment.source.is_a?(Pay::Subscription)
123
- base_metadata[:subscription_id] = @fulfillment.source.id
132
+ base_metadata["subscription_id"] = @fulfillment.source.id
124
133
  end
125
134
 
126
135
  @fulfillment.metadata.merge(base_metadata)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UsageCredits
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,15 +1,34 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: usage_credits
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-02-14 00:00:00.000000000 Z
10
+ date: 2025-12-29 00:00:00.000000000 Z
12
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: pay
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.3'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '12.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '8.3'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '12.0'
13
32
  - !ruby/object:Gem::Dependency
14
33
  name: rails
15
34
  requirement: !ruby/object:Gem::Requirement
@@ -25,19 +44,103 @@ dependencies:
25
44
  - !ruby/object:Gem::Version
26
45
  version: '6.1'
27
46
  - !ruby/object:Gem::Dependency
28
- name: pay
47
+ name: bundler
29
48
  requirement: !ruby/object:Gem::Requirement
30
49
  requirements:
31
50
  - - "~>"
32
51
  - !ruby/object:Gem::Version
33
- version: '8.3'
34
- type: :runtime
52
+ version: '2.0'
53
+ type: :development
35
54
  prerelease: false
36
55
  version_requirements: !ruby/object:Gem::Requirement
37
56
  requirements:
38
57
  - - "~>"
39
58
  - !ruby/object:Gem::Version
40
- version: '8.3'
59
+ version: '2.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rake
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '13.0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '13.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: minitest
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '5.0'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '5.0'
88
+ - !ruby/object:Gem::Dependency
89
+ name: sqlite3
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '2.1'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '2.1'
102
+ - !ruby/object:Gem::Dependency
103
+ name: rubocop
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '1.0'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '1.0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: rubocop-minitest
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '0.35'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '0.35'
130
+ - !ruby/object:Gem::Dependency
131
+ name: rubocop-performance
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '1.0'
137
+ type: :development
138
+ prerelease: false
139
+ version_requirements: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '1.0'
41
144
  description: Add a usage-based credit system to your Rails app, easily. Let users
42
145
  buy and spend credits on usage-based actions. Refill Stripe subscriptions with credits,
43
146
  sell one-time booster credit packs, implement PAYG (pay-as-you-go) billing, award
@@ -51,10 +154,18 @@ extensions: []
51
154
  extra_rdoc_files: []
52
155
  files:
53
156
  - ".rubocop.yml"
157
+ - ".simplecov"
158
+ - AGENTS.md
159
+ - Appraisals
54
160
  - CHANGELOG.md
161
+ - CLAUDE.md
55
162
  - LICENSE.txt
56
163
  - README.md
57
164
  - Rakefile
165
+ - gemfiles/pay_10.0.gemfile
166
+ - gemfiles/pay_11.0.gemfile
167
+ - gemfiles/pay_8.3.gemfile
168
+ - gemfiles/pay_9.0.gemfile
58
169
  - lib/generators/usage_credits/install_generator.rb
59
170
  - lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb
60
171
  - lib/generators/usage_credits/templates/initializer.rb
@@ -93,7 +204,6 @@ metadata:
93
204
  source_code_uri: https://github.com/rameerez/usage_credits
94
205
  changelog_uri: https://github.com/rameerez/usage_credits/blob/main/CHANGELOG.md
95
206
  rubygems_mfa_required: 'true'
96
- post_install_message:
97
207
  rdoc_options: []
98
208
  require_paths:
99
209
  - lib
@@ -108,8 +218,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
218
  - !ruby/object:Gem::Version
109
219
  version: '0'
110
220
  requirements: []
111
- rubygems_version: 3.5.22
112
- signing_key:
221
+ rubygems_version: 3.6.2
113
222
  specification_version: 4
114
223
  summary: Add usage-based credits to your Rails app.
115
224
  test_files: []