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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e1f36f964eea89e835d789f34b6b2ce8994940896d21652af2400282c0e24a0
4
- data.tar.gz: ad7c6be299aee7f89929841bfe18a6f44e2dd91361afe30ff238980bdc75544b
3
+ metadata.gz: 6947ef76d1f3674d94e3fbff6f4149daff82f0e90e55365c485f2e6eef88b319
4
+ data.tar.gz: 784a9c5fb5ca6b139bf04bab6f85ddff1d96a15e30cd57f7d8b2f9f167f0aac6
5
5
  SHA512:
6
- metadata.gz: 4047f5a333b5e1359468468927100ea71a5c5b7117d8eec9e73a81ad8b40c243d796dd3d0e37604c9fd46b9b44239612d5bc1b30176e4322364312cc1c900017
7
- data.tar.gz: b21b55c0b524b18fcf47339b926a425f53fcca5a3148f221bc8e86e4976f1281425041edd1d9cc903b68ab6cf82dd38a1a97df84373d1658d021cc43b3308de0
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
- # For other adapters (e.g. SQLite, MySQL), try using JSON_EXTRACT.
115
- transactions.exists?(["json_extract(metadata, '$.purchase_charge_id') = ? AND json_extract(metadata, '$.credits_fulfilled') = ?", id, true])
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/MySQL with JSON_EXTRACT
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, true
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
  # =========================================
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UsageCredits
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.3.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-15 00:00:00.000000000 Z
10
+ date: 2026-01-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: pay