usage_credits 0.3.0 → 0.4.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 +5 -0
- data/README.md +29 -0
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +11 -3
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +17 -4
- data/lib/usage_credits/models/fulfillment.rb +36 -0
- data/lib/usage_credits/models/transaction.rb +45 -0
- data/lib/usage_credits/models/wallet.rb +56 -0
- data/lib/usage_credits/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6947ef76d1f3674d94e3fbff6f4149daff82f0e90e55365c485f2e6eef88b319
|
|
4
|
+
data.tar.gz: 784a9c5fb5ca6b139bf04bab6f85ddff1d96a15e30cd57f7d8b2f9f167f0aac6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 54d40b947cda3d9da7932bf1da5c8f8bb97c68c359aa087c8913c2e18ca8bab9500d257c275d02070bcbf5122a4550c83b37205a76080a2db7c598f87366a9d9
|
|
7
|
+
data.tar.gz: 8d6e8689d9271526b768472fb8af5ee74caab0dfdb729750d38d0168eb6cf4e6f3f9a0daf034bfbae048a619c38cd8f19c5c2d3b821a797bcbb1db99d85267d2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## [0.4.0] - 2026-01-16
|
|
2
|
+
|
|
3
|
+
- Add `balance_before` and `balance_after` to transactions by @rameerez (h/t @yshmarov) in https://github.com/rameerez/usage_credits/pull/27
|
|
4
|
+
- Add MySQL support and multi-database CI testing by @rameerez in https://github.com/rameerez/usage_credits/pull/28
|
|
5
|
+
|
|
1
6
|
## [0.3.0] - 2026-01-15
|
|
2
7
|
|
|
3
8
|
- Add lifecycle callbacks by @rameerez in https://github.com/rameerez/usage_credits/pull/25
|
data/README.md
CHANGED
|
@@ -578,6 +578,35 @@ This makes it easy to:
|
|
|
578
578
|
- Generate detailed invoices
|
|
579
579
|
- Monitor usage patterns
|
|
580
580
|
|
|
581
|
+
### Running balance (balance after each transaction)
|
|
582
|
+
|
|
583
|
+
Every transaction automatically tracks the wallet balance before and after it was applied, like you would find in a bank statement:
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
user.credit_history.each do |tx|
|
|
587
|
+
puts "#{tx.created_at.strftime('%Y-%m-%d')}: #{tx.formatted_amount}"
|
|
588
|
+
puts " Balance: #{tx.balance_before} → #{tx.balance_after}"
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Output:
|
|
592
|
+
# 2024-12-16: +1000 credits
|
|
593
|
+
# Balance: 0 → 1000
|
|
594
|
+
# 2024-12-26: +500 credits
|
|
595
|
+
# Balance: 1000 → 1500
|
|
596
|
+
# 2025-01-14: -50 credits
|
|
597
|
+
# Balance: 1500 → 1450
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
This is useful for building transaction history UIs, generating statements, or debugging balance issues. Each transaction provides:
|
|
601
|
+
|
|
602
|
+
```ruby
|
|
603
|
+
transaction.balance_before # Balance before this transaction
|
|
604
|
+
transaction.balance_after # Balance after this transaction
|
|
605
|
+
transaction.formatted_balance_after # Formatted (e.g., "1450 credits")
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
`balance_before` and `balance_after` return `nil` if no balance is found (for transactions created before this feature was added)
|
|
609
|
+
|
|
581
610
|
### Custom credit formatting
|
|
582
611
|
|
|
583
612
|
A minor thing, but if you want to use the `@transaction.formatted_amount` helper, you can specify the format:
|
|
@@ -7,7 +7,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
7
7
|
create_table :usage_credits_wallets, id: primary_key_type do |t|
|
|
8
8
|
t.references :owner, polymorphic: true, null: false, type: foreign_key_type
|
|
9
9
|
t.integer :balance, null: false, default: 0
|
|
10
|
-
t.send(json_column_type, :metadata, null: false, default:
|
|
10
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
11
11
|
|
|
12
12
|
t.timestamps
|
|
13
13
|
end
|
|
@@ -18,7 +18,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
18
18
|
t.string :category, null: false
|
|
19
19
|
t.datetime :expires_at
|
|
20
20
|
t.references :fulfillment, type: foreign_key_type
|
|
21
|
-
t.send(json_column_type, :metadata, null: false, default:
|
|
21
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
22
22
|
|
|
23
23
|
t.timestamps
|
|
24
24
|
end
|
|
@@ -32,7 +32,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
32
32
|
t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed)
|
|
33
33
|
t.string :fulfillment_period # "2.months", "15.days", etc. (nil for one-time)
|
|
34
34
|
t.datetime :stops_at # When to stop performing fulfillments
|
|
35
|
-
t.send(json_column_type, :metadata, null: false, default:
|
|
35
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
36
36
|
|
|
37
37
|
t.timestamps
|
|
38
38
|
end
|
|
@@ -85,4 +85,12 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
85
85
|
return :jsonb if connection.adapter_name.downcase.include?('postgresql')
|
|
86
86
|
:json
|
|
87
87
|
end
|
|
88
|
+
|
|
89
|
+
# MySQL 8+ doesn't allow default values on JSON columns.
|
|
90
|
+
# Returns an empty hash default for SQLite/PostgreSQL, nil for MySQL.
|
|
91
|
+
# Models handle nil metadata gracefully by defaulting to {} in their accessors.
|
|
92
|
+
def json_column_default
|
|
93
|
+
return nil if connection.adapter_name.downcase.include?('mysql')
|
|
94
|
+
{}
|
|
95
|
+
end
|
|
88
96
|
end
|
|
@@ -110,9 +110,15 @@ module UsageCredits
|
|
|
110
110
|
if adapter.include?("postgres")
|
|
111
111
|
# PostgreSQL supports the @> JSON containment operator.
|
|
112
112
|
transactions.exists?(['metadata @> ?', { purchase_charge_id: id, credits_fulfilled: true }.to_json])
|
|
113
|
+
elsif adapter.include?("mysql")
|
|
114
|
+
# MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison
|
|
115
|
+
transactions.exists?([
|
|
116
|
+
"JSON_EXTRACT(metadata, '$.purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_fulfilled') = CAST('true' AS JSON)",
|
|
117
|
+
id
|
|
118
|
+
])
|
|
113
119
|
else
|
|
114
|
-
#
|
|
115
|
-
transactions.exists?(["json_extract(metadata, '$.purchase_charge_id') = ? AND json_extract(metadata, '$.credits_fulfilled') = ?", id,
|
|
120
|
+
# SQLite: json_extract returns SQL values (true becomes 1)
|
|
121
|
+
transactions.exists?(["json_extract(metadata, '$.purchase_charge_id') = ? AND json_extract(metadata, '$.credits_fulfilled') = ?", id, 1])
|
|
116
122
|
end
|
|
117
123
|
rescue ActiveRecord::StatementInvalid
|
|
118
124
|
# If the SQL query fails (for example, if JSON_EXTRACT isn’t supported),
|
|
@@ -229,11 +235,18 @@ module UsageCredits
|
|
|
229
235
|
{ refunded_purchase_charge_id: id, credits_refunded: true }.to_json
|
|
230
236
|
)
|
|
231
237
|
return filtered.sum { |tx| -tx.amount }
|
|
238
|
+
elsif adapter.include?("mysql")
|
|
239
|
+
# MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison
|
|
240
|
+
filtered = transactions.where(
|
|
241
|
+
"JSON_EXTRACT(metadata, '$.refunded_purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_refunded') = CAST('true' AS JSON)",
|
|
242
|
+
id
|
|
243
|
+
)
|
|
244
|
+
return filtered.sum { |tx| -tx.amount }
|
|
232
245
|
else
|
|
233
|
-
# SQLite
|
|
246
|
+
# SQLite: json_extract returns SQL values (true becomes 1)
|
|
234
247
|
filtered = transactions.where(
|
|
235
248
|
"json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?",
|
|
236
|
-
id,
|
|
249
|
+
id, 1
|
|
237
250
|
)
|
|
238
251
|
return filtered.sum { |tx| -tx.amount }
|
|
239
252
|
end
|
|
@@ -18,6 +18,31 @@ module UsageCredits
|
|
|
18
18
|
validates :next_fulfillment_at, comparison: { greater_than: :last_fulfilled_at },
|
|
19
19
|
if: -> { recurring? && last_fulfilled_at.present? && next_fulfillment_at.present? }
|
|
20
20
|
|
|
21
|
+
# =========================================
|
|
22
|
+
# Metadata Handling
|
|
23
|
+
# =========================================
|
|
24
|
+
|
|
25
|
+
# Sync in-place modifications to metadata before saving
|
|
26
|
+
before_save :sync_metadata_cache
|
|
27
|
+
|
|
28
|
+
# Get metadata with indifferent access (string/symbol keys)
|
|
29
|
+
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
30
|
+
def metadata
|
|
31
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Set metadata, ensuring consistent storage format
|
|
35
|
+
def metadata=(hash)
|
|
36
|
+
@indifferent_metadata = nil # Clear cache
|
|
37
|
+
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Clear metadata cache on reload to ensure fresh data from database
|
|
41
|
+
def reload(*)
|
|
42
|
+
@indifferent_metadata = nil
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
21
46
|
# Only get fulfillments that are due AND not stopped
|
|
22
47
|
scope :due_for_fulfillment, -> {
|
|
23
48
|
where("next_fulfillment_at <= ?", Time.current)
|
|
@@ -65,6 +90,17 @@ module UsageCredits
|
|
|
65
90
|
|
|
66
91
|
private
|
|
67
92
|
|
|
93
|
+
# Sync in-place modifications to the cached metadata back to the attribute
|
|
94
|
+
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
95
|
+
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
96
|
+
def sync_metadata_cache
|
|
97
|
+
if @indifferent_metadata
|
|
98
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
99
|
+
elsif read_attribute(:metadata).nil?
|
|
100
|
+
write_attribute(:metadata, {})
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
68
104
|
def valid_fulfillment_period_format
|
|
69
105
|
unless UsageCredits::PeriodParser.valid_period_format?(fulfillment_period)
|
|
70
106
|
errors.add(:fulfillment_period, "must be in format like '2.months' or '15.days' and use supported units")
|
|
@@ -116,6 +116,23 @@ module UsageCredits
|
|
|
116
116
|
amount - allocated_amount
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# =========================================
|
|
120
|
+
# Balance After Transaction
|
|
121
|
+
# =========================================
|
|
122
|
+
|
|
123
|
+
# Get the balance after this transaction was applied
|
|
124
|
+
# Returns nil for transactions created before this feature was added
|
|
125
|
+
def balance_after
|
|
126
|
+
metadata[:balance_after]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get the balance before this transaction was applied
|
|
130
|
+
# Returns the stored value if available, otherwise nil
|
|
131
|
+
# Note: For transactions created before this feature, returns nil
|
|
132
|
+
def balance_before
|
|
133
|
+
metadata[:balance_before]
|
|
134
|
+
end
|
|
135
|
+
|
|
119
136
|
# =========================================
|
|
120
137
|
# Display Formatting
|
|
121
138
|
# =========================================
|
|
@@ -126,6 +143,13 @@ module UsageCredits
|
|
|
126
143
|
"#{prefix}#{UsageCredits.configuration.credit_formatter.call(amount)}"
|
|
127
144
|
end
|
|
128
145
|
|
|
146
|
+
# Format the balance after for display (e.g., "500 credits")
|
|
147
|
+
# Returns nil if balance_after is not stored
|
|
148
|
+
def formatted_balance_after
|
|
149
|
+
return nil unless balance_after
|
|
150
|
+
UsageCredits.configuration.credit_formatter.call(balance_after)
|
|
151
|
+
end
|
|
152
|
+
|
|
129
153
|
# Get a human-readable description of what this transaction represents
|
|
130
154
|
def description
|
|
131
155
|
# Custom description takes precedence
|
|
@@ -142,7 +166,11 @@ module UsageCredits
|
|
|
142
166
|
# Metadata Handling
|
|
143
167
|
# =========================================
|
|
144
168
|
|
|
169
|
+
# Sync in-place modifications to metadata before saving
|
|
170
|
+
before_save :sync_metadata_cache
|
|
171
|
+
|
|
145
172
|
# Get metadata with indifferent access (string/symbol keys)
|
|
173
|
+
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
146
174
|
def metadata
|
|
147
175
|
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
148
176
|
end
|
|
@@ -153,8 +181,25 @@ module UsageCredits
|
|
|
153
181
|
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
154
182
|
end
|
|
155
183
|
|
|
184
|
+
# Clear metadata cache on reload to ensure fresh data from database
|
|
185
|
+
def reload(*)
|
|
186
|
+
@indifferent_metadata = nil
|
|
187
|
+
super
|
|
188
|
+
end
|
|
189
|
+
|
|
156
190
|
private
|
|
157
191
|
|
|
192
|
+
# Sync in-place modifications to the cached metadata back to the attribute
|
|
193
|
+
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
194
|
+
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
195
|
+
def sync_metadata_cache
|
|
196
|
+
if @indifferent_metadata
|
|
197
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
198
|
+
elsif read_attribute(:metadata).nil?
|
|
199
|
+
write_attribute(:metadata, {})
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
158
203
|
# Format operation charge descriptions (e.g., "Process Video (-10 credits)")
|
|
159
204
|
def operation_description
|
|
160
205
|
operation = metadata["operation"]&.to_s&.titleize
|
|
@@ -25,6 +25,31 @@ module UsageCredits
|
|
|
25
25
|
|
|
26
26
|
validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance?
|
|
27
27
|
|
|
28
|
+
# =========================================
|
|
29
|
+
# Metadata Handling
|
|
30
|
+
# =========================================
|
|
31
|
+
|
|
32
|
+
# Sync in-place modifications to metadata before saving
|
|
33
|
+
before_save :sync_metadata_cache
|
|
34
|
+
|
|
35
|
+
# Get metadata with indifferent access (string/symbol keys)
|
|
36
|
+
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
37
|
+
def metadata
|
|
38
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Set metadata, ensuring consistent storage format
|
|
42
|
+
def metadata=(hash)
|
|
43
|
+
@indifferent_metadata = nil # Clear cache
|
|
44
|
+
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Clear metadata cache on reload to ensure fresh data from database
|
|
48
|
+
def reload(*)
|
|
49
|
+
@indifferent_metadata = nil
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
28
53
|
# =========================================
|
|
29
54
|
# Credit Balance & History
|
|
30
55
|
# =========================================
|
|
@@ -183,6 +208,16 @@ module UsageCredits
|
|
|
183
208
|
self.balance = credits
|
|
184
209
|
save!
|
|
185
210
|
|
|
211
|
+
# Store balance information in transaction metadata for audit trail.
|
|
212
|
+
# Note: This update! is in the same DB transaction as the create! above (via with_lock),
|
|
213
|
+
# so if this fails, the entire transaction rolls back - no orphaned records possible.
|
|
214
|
+
# We intentionally overwrite any user-supplied balance_before/balance_after keys
|
|
215
|
+
# to ensure system-set values are authoritative.
|
|
216
|
+
transaction.update!(metadata: transaction.metadata.merge(
|
|
217
|
+
balance_before: previous_balance,
|
|
218
|
+
balance_after: balance
|
|
219
|
+
))
|
|
220
|
+
|
|
186
221
|
# Dispatch callback with full context
|
|
187
222
|
UsageCredits::Callbacks.dispatch(:credits_added,
|
|
188
223
|
wallet: self,
|
|
@@ -277,6 +312,16 @@ module UsageCredits
|
|
|
277
312
|
self.balance = credits
|
|
278
313
|
save!
|
|
279
314
|
|
|
315
|
+
# Store balance information in transaction metadata for audit trail.
|
|
316
|
+
# Note: This update! is in the same DB transaction as the create! above (via with_lock),
|
|
317
|
+
# so if this fails, the entire transaction rolls back - no orphaned records possible.
|
|
318
|
+
# We intentionally overwrite any user-supplied balance_before/balance_after keys
|
|
319
|
+
# to ensure system-set values are authoritative.
|
|
320
|
+
spend_tx.update!(metadata: spend_tx.metadata.merge(
|
|
321
|
+
balance_before: previous_balance,
|
|
322
|
+
balance_after: balance
|
|
323
|
+
))
|
|
324
|
+
|
|
280
325
|
# Dispatch credits_deducted callback
|
|
281
326
|
UsageCredits::Callbacks.dispatch(:credits_deducted,
|
|
282
327
|
wallet: self,
|
|
@@ -314,6 +359,17 @@ module UsageCredits
|
|
|
314
359
|
|
|
315
360
|
private
|
|
316
361
|
|
|
362
|
+
# Sync in-place modifications to the cached metadata back to the attribute
|
|
363
|
+
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
364
|
+
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
365
|
+
def sync_metadata_cache
|
|
366
|
+
if @indifferent_metadata
|
|
367
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
368
|
+
elsif read_attribute(:metadata).nil?
|
|
369
|
+
write_attribute(:metadata, {})
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
317
373
|
# =========================================
|
|
318
374
|
# Helper Methods
|
|
319
375
|
# =========================================
|
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: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-01-
|
|
10
|
+
date: 2026-01-16 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: pay
|