usage_credits 0.3.0 → 0.5.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: 707a34fdb4e9cfa1ca2cb0ccd128baf514c3cc68512afdd93706c288172f296f
4
+ data.tar.gz: 847ecdc0ffef49fe9b6a948dc6d199fa88ab1ea0d4c5486b26ff5b55deb4838c
5
5
  SHA512:
6
- metadata.gz: 4047f5a333b5e1359468468927100ea71a5c5b7117d8eec9e73a81ad8b40c243d796dd3d0e37604c9fd46b9b44239612d5bc1b30176e4322364312cc1c900017
7
- data.tar.gz: b21b55c0b524b18fcf47339b926a425f53fcca5a3148f221bc8e86e4976f1281425041edd1d9cc903b68ab6cf82dd38a1a97df84373d1658d021cc43b3308de0
6
+ metadata.gz: 2d778d2cd412b1754eb79a34d9f405241bd38461dbaf5021e1c948c570b09ffc36676ab742b23868754d7c9ddc94259afb383ff4e987abf26f243e3ed20ea767
7
+ data.tar.gz: b918f017dddbbd325db6e71c4d4163a01164c0d5ebf3007f07a0e0853bc0c898a486eb6905a6327ae7ffe02d7dda5692a12111694c592318222740e6b1ae57ed
data/.simplecov CHANGED
@@ -1,48 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- SimpleCov.start 'rails' do
4
- # Coverage directory
5
- coverage_dir 'coverage'
3
+ # SimpleCov configuration file (auto-loaded before test suite)
4
+ # This keeps test_helper.rb clean and follows best practices
6
5
 
7
- # Enable branch coverage (must be before minimum_coverage)
6
+ SimpleCov.start do
7
+ # Use SimpleFormatter for terminal-only output (no HTML generation)
8
+ formatter SimpleCov::Formatter::SimpleFormatter
9
+
10
+ # Track coverage for the lib directory (gem source code)
11
+ add_filter "/test/"
12
+
13
+ # Track Ruby files in lib directory
14
+ track_files "lib/**/*.rb"
15
+
16
+ # Enable branch coverage for more detailed metrics
8
17
  enable_coverage :branch
9
18
 
10
19
  # 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'
20
+ # Current coverage: Line 88.56%, Branch 81.17%
21
+ minimum_coverage line: 80, branch: 75
33
22
 
34
23
  # Disambiguate parallel test runs
35
24
  command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
25
+ end
36
26
 
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
27
+ # Print coverage summary to terminal after tests complete
28
+ SimpleCov.at_exit do
29
+ SimpleCov.result.format!
30
+ puts "\n" + "=" * 60
31
+ puts "COVERAGE SUMMARY"
32
+ puts "=" * 60
33
+ puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
34
+ branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A"
35
+ puts "Branch Coverage: #{branch_coverage}%"
36
+ puts "=" * 60
48
37
  end
data/AGENTS.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # AGENTS.md
2
2
 
3
- This file provides guidance to AI Agents (like OpenAI's Codex, Claude Code, etc) when working with code in this repository.
3
+ This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
4
4
 
5
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/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## [0.5.0] - 2026-03-15
2
+
3
+ - Add configurable transaction categories via `config.additional_categories` for money-like wallet use cases (marketplaces, fintech) by @rameerez in https://github.com/rameerez/usage_credits/pull/29
4
+
5
+ ## [0.4.0] - 2026-01-16
6
+
7
+ - Add `balance_before` and `balance_after` to transactions by @rameerez (h/t @yshmarov) in https://github.com/rameerez/usage_credits/pull/27
8
+ - Add MySQL support and multi-database CI testing by @rameerez in https://github.com/rameerez/usage_credits/pull/28
9
+
1
10
  ## [0.3.0] - 2026-01-15
2
11
 
3
12
  - Add lifecycle callbacks by @rameerez in https://github.com/rameerez/usage_credits/pull/25
data/README.md CHANGED
@@ -3,11 +3,13 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/usage_credits.svg)](https://badge.fury.io/rb/usage_credits) [![Build Status](https://github.com/rameerez/usage_credits/workflows/Tests/badge.svg)](https://github.com/rameerez/usage_credits/actions)
4
4
 
5
5
  > [!TIP]
6
- > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks.
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=usage_credits)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=usage_credits)!
7
7
 
8
8
  `usage_credits` allows your users to have in-app credits / tokens they can use to perform operations.
9
9
 
10
- ✨ Perfect for SaaS, AI apps, games, and API products that want to implement usage-based pricing.
10
+ ✨ Perfect for SaaS, AI apps, games, API products, and **marketplace wallets** that want to implement usage-based pricing or track money-like balances.
11
+
12
+ > **Not just for credits!** While the gem is called "usage_credits", it's built on a production-grade double-entry ledger with row-level locking, FIFO allocation, and full audit trails. You can use it for marketplace seller balances, in-app wallets, reward points, or any system that needs to track money-like assets with proper accounting. [See the "Beyond credits" section](#beyond-credits-using-this-gem-for-money-like-wallets-and-payouts) for examples.
11
13
 
12
14
  [ 🟢 [Live interactive demo website](https://usagecredits.com/) ] [ 🎥 [Quick video overview](https://x.com/rameerez/status/1890419563189195260) ]
13
15
 
@@ -578,6 +580,35 @@ This makes it easy to:
578
580
  - Generate detailed invoices
579
581
  - Monitor usage patterns
580
582
 
583
+ ### Running balance (balance after each transaction)
584
+
585
+ Every transaction automatically tracks the wallet balance before and after it was applied, like you would find in a bank statement:
586
+
587
+ ```ruby
588
+ user.credit_history.each do |tx|
589
+ puts "#{tx.created_at.strftime('%Y-%m-%d')}: #{tx.formatted_amount}"
590
+ puts " Balance: #{tx.balance_before} → #{tx.balance_after}"
591
+ end
592
+
593
+ # Output:
594
+ # 2024-12-16: +1000 credits
595
+ # Balance: 0 → 1000
596
+ # 2024-12-26: +500 credits
597
+ # Balance: 1000 → 1500
598
+ # 2025-01-14: -50 credits
599
+ # Balance: 1500 → 1450
600
+ ```
601
+
602
+ This is useful for building transaction history UIs, generating statements, or debugging balance issues. Each transaction provides:
603
+
604
+ ```ruby
605
+ transaction.balance_before # Balance before this transaction
606
+ transaction.balance_after # Balance after this transaction
607
+ transaction.formatted_balance_after # Formatted (e.g., "1450 credits")
608
+ ```
609
+
610
+ `balance_before` and `balance_after` return `nil` if no balance is found (for transactions created before this feature was added)
611
+
581
612
  ### Custom credit formatting
582
613
 
583
614
  A minor thing, but if you want to use the `@transaction.formatted_amount` helper, you can specify the format:
@@ -598,6 +629,146 @@ Which will get you:
598
629
 
599
630
  It's useful if you want to name your credits something else (tokens, virtual currency, tasks, in-app gems, whatever) and you want the name to be consistent.
600
631
 
632
+ ## Beyond credits: using this gem for money-like wallets and payouts
633
+
634
+ While this gem is called `usage_credits`, the underlying architecture is a **production-grade double-entry ledger** with row-level locking, FIFO allocation, and full audit trails. This makes it suitable for more than just API credits — you can use it as a wallet system for **money-like assets**, **marketplace payouts**, **in-app balances**, and more.
635
+
636
+ ### Custom transaction categories
637
+
638
+ By default, the gem includes categories like `signup_bonus`, `operation_charge`, `subscription_credits`, etc. But you can extend these with your own categories for your specific use case:
639
+
640
+ ```ruby
641
+ # config/initializers/usage_credits.rb
642
+ UsageCredits.configure do |config|
643
+ # Add custom categories for your business logic
644
+ config.additional_categories = %w[
645
+ payment_received
646
+ payment_sent
647
+ payout_requested
648
+ platform_fee
649
+ refund
650
+ tip
651
+ cashback
652
+ ]
653
+ end
654
+ ```
655
+
656
+ These custom categories work exactly like the built-in ones — they're validated, tracked in transaction history, and available for filtering/querying.
657
+
658
+ ### Example: Marketplace with seller payouts
659
+
660
+ Imagine you're building a marketplace where sellers earn money and can withdraw their balance. Here's how you'd implement it with `usage_credits`:
661
+
662
+ ```ruby
663
+ # app/models/user.rb
664
+ class User < ApplicationRecord
665
+ has_credits # Each user gets a wallet
666
+
667
+ def request_payout(amount_cents)
668
+ # In production, wrap in wallet.with_lock { } to prevent race conditions
669
+ raise "Insufficient balance" if credits < amount_cents
670
+
671
+ wallet.deduct_credits(
672
+ amount_cents,
673
+ category: :payout_requested,
674
+ metadata: {
675
+ requested_at: Time.current,
676
+ payout_method: stripe_account_id
677
+ }
678
+ )
679
+
680
+ PayoutJob.perform_later(self, amount_cents)
681
+ end
682
+ end
683
+
684
+ # app/services/order_payment_service.rb
685
+ class OrderPaymentService
686
+ def initialize(order)
687
+ @order = order
688
+ @buyer = order.buyer
689
+ @seller = order.seller
690
+ end
691
+
692
+ def process!
693
+ platform_fee = (@order.total_cents * 0.10).to_i # 10% fee
694
+ seller_amount = @order.total_cents - platform_fee
695
+
696
+ ActiveRecord::Base.transaction do
697
+ # Credit the seller (net of platform fee)
698
+ @seller.wallet.add_credits(
699
+ seller_amount,
700
+ category: :payment_received,
701
+ metadata: {
702
+ order_id: @order.id,
703
+ gross_amount: @order.total_cents,
704
+ platform_fee: platform_fee,
705
+ buyer_id: @buyer.id
706
+ }
707
+ )
708
+
709
+ @order.update!(paid: true)
710
+ end
711
+ end
712
+ end
713
+ ```
714
+
715
+ Now you have:
716
+ - **Full audit trail**: Every transaction is logged with metadata
717
+ - **Balance tracking**: `@seller.credits` returns current balance in cents
718
+ - **Transaction history**: `@seller.credit_history.by_category(:payment_received)`
719
+ - **Concurrency safety**: Row-level locks prevent double-spending
720
+ - **Running balance**: Each transaction stores `balance_before` and `balance_after`
721
+
722
+ ```ruby
723
+ # Show transaction history with running balance
724
+ @seller.credit_history.recent.each do |tx|
725
+ puts "#{tx.created_at.strftime('%Y-%m-%d')}: #{tx.category}"
726
+ puts " Amount: #{tx.amount} cents"
727
+ puts " Balance: #{tx.balance_before} → #{tx.balance_after}"
728
+ puts " Order: ##{tx.metadata['order_id']}" if tx.metadata['order_id']
729
+ end
730
+ ```
731
+
732
+ ### Why this works for money
733
+
734
+ The gem's architecture gives you everything you'd need for a money-handling system:
735
+
736
+ | Feature | How it helps |
737
+ |---------|--------------|
738
+ | Double-entry ledger | Every credit has a corresponding debit source tracked via allocations |
739
+ | Immutable transactions | Append-only — no edits, only new entries (required for financial audit) |
740
+ | Row-level locking | Prevents race conditions and double-spending |
741
+ | FIFO allocation | When spending, oldest credits are used first (important for expiring balances) |
742
+ | Balance snapshots | Each transaction records balance before/after for reconciliation |
743
+ | Rich metadata | Store order IDs, user IDs, payment references — whatever you need for audit |
744
+
745
+ ### A note on multi-currency
746
+
747
+ Currently, the gem uses a single currency per installation (configured via `config.default_currency`). All amounts are stored as integers (cents) to avoid floating-point issues.
748
+
749
+ If you need multi-currency support, you could:
750
+ 1. Store amounts in the smallest unit of each currency (cents, pence, etc.)
751
+ 2. Use metadata to track the currency per transaction
752
+ 3. Handle conversion at the application layer
753
+
754
+ Multi-currency wallets (one wallet per currency per user) is on the roadmap for a future version. For now, if you need this, you'd run separate wallet instances or handle it at the application level.
755
+
756
+ ### Naming your "credits"
757
+
758
+ Remember you can customize how credits are displayed:
759
+
760
+ ```ruby
761
+ UsageCredits.configure do |config|
762
+ config.format_credits do |amount|
763
+ # Display as money
764
+ "$#{(amount / 100.0).round(2)}"
765
+ end
766
+ end
767
+
768
+ @user.credit_history.last.formatted_amount
769
+ # => "+$25.00"
770
+ ```
771
+
601
772
  ## Demo Rails app
602
773
 
603
774
  There's a demo Rails app showcasing the features in the `usage_credits` gem under `test/dummy`. It's currently deployed to `usagecredits.com`. If you want to run it yourself locally, you can just clone this repo, `cd` into the `test/dummy` folder, and then `bundle` and `rails s` to launch it. You can examine the code of the demo app to better understand the gem.
@@ -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
- 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
96
+ end
@@ -30,6 +30,9 @@ module UsageCredits
30
30
 
31
31
  attr_reader :fulfillment_grace_period
32
32
 
33
+ # Custom transaction categories that extend the default set
34
+ attr_reader :additional_categories
35
+
33
36
  # Minimum allowed fulfillment period for subscription plans.
34
37
  # Defaults to 1.day to prevent accidental 1-second refill loops in production.
35
38
  # Can be set to shorter periods (e.g., 2.seconds) in development/test for faster iteration.
@@ -80,6 +83,9 @@ module UsageCredits
80
83
  # Minimum fulfillment period - prevents accidental 1-second refill loops in production
81
84
  @min_fulfillment_period = 1.day
82
85
 
86
+ # Custom transaction categories (empty by default, apps can extend)
87
+ @additional_categories = []
88
+
83
89
  @allow_negative_balance = false
84
90
  @low_balance_threshold = nil
85
91
  @low_balance_callback = nil # Called when user hits low_balance_threshold
@@ -201,6 +207,18 @@ module UsageCredits
201
207
  @min_fulfillment_period = value
202
208
  end
203
209
 
210
+ # Set additional transaction categories with validation
211
+ # These extend the default categories defined in Transaction::DEFAULT_CATEGORIES
212
+ # @param categories [Array<String, Symbol>] Array of category names
213
+ def additional_categories=(categories)
214
+ raise ArgumentError, "Additional categories must be an array" unless categories.is_a?(Array)
215
+
216
+ # Convert to strings and filter out blank values
217
+ validated = categories.map { |cat| cat.to_s.strip }.reject(&:blank?)
218
+
219
+ @additional_categories = validated
220
+ end
221
+
204
222
  # =========================================
205
223
  # Callback & Formatter Configuration
206
224
  # =========================================
@@ -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")
@@ -15,8 +15,8 @@ module UsageCredits
15
15
  # Transaction Categories
16
16
  # =========================================
17
17
 
18
- # All possible transaction types, grouped by purpose:
19
- CATEGORIES = [
18
+ # Default transaction types, grouped by purpose:
19
+ DEFAULT_CATEGORIES = [
20
20
  # Bonus credits
21
21
  "signup_bonus", # Initial signup bonus
22
22
  "referral_bonus", # Referral reward bonus
@@ -40,6 +40,16 @@ module UsageCredits
40
40
  "credit_deducted" # Generic deduction
41
41
  ].freeze
42
42
 
43
+ # All valid categories: defaults + any custom categories added via config
44
+ # @return [Array<String>] Combined list of valid category names
45
+ def self.categories
46
+ (DEFAULT_CATEGORIES + UsageCredits.configuration.additional_categories).uniq
47
+ end
48
+
49
+ # Backwards compatibility: CATEGORIES constant still works
50
+ # but prefer using Transaction.categories for dynamic lookup
51
+ CATEGORIES = DEFAULT_CATEGORIES
52
+
43
53
  # =========================================
44
54
  # Associations & Validations
45
55
  # =========================================
@@ -59,7 +69,7 @@ module UsageCredits
59
69
  dependent: :destroy
60
70
 
61
71
  validates :amount, presence: true, numericality: { only_integer: true }
62
- validates :category, presence: true, inclusion: { in: CATEGORIES }
72
+ validates :category, presence: true, inclusion: { in: ->(record) { Transaction.categories } }
63
73
 
64
74
  validate :remaining_amount_cannot_be_negative
65
75
 
@@ -116,6 +126,23 @@ module UsageCredits
116
126
  amount - allocated_amount
117
127
  end
118
128
 
129
+ # =========================================
130
+ # Balance After Transaction
131
+ # =========================================
132
+
133
+ # Get the balance after this transaction was applied
134
+ # Returns nil for transactions created before this feature was added
135
+ def balance_after
136
+ metadata[:balance_after]
137
+ end
138
+
139
+ # Get the balance before this transaction was applied
140
+ # Returns the stored value if available, otherwise nil
141
+ # Note: For transactions created before this feature, returns nil
142
+ def balance_before
143
+ metadata[:balance_before]
144
+ end
145
+
119
146
  # =========================================
120
147
  # Display Formatting
121
148
  # =========================================
@@ -126,6 +153,13 @@ module UsageCredits
126
153
  "#{prefix}#{UsageCredits.configuration.credit_formatter.call(amount)}"
127
154
  end
128
155
 
156
+ # Format the balance after for display (e.g., "500 credits")
157
+ # Returns nil if balance_after is not stored
158
+ def formatted_balance_after
159
+ return nil unless balance_after
160
+ UsageCredits.configuration.credit_formatter.call(balance_after)
161
+ end
162
+
129
163
  # Get a human-readable description of what this transaction represents
130
164
  def description
131
165
  # Custom description takes precedence
@@ -142,7 +176,11 @@ module UsageCredits
142
176
  # Metadata Handling
143
177
  # =========================================
144
178
 
179
+ # Sync in-place modifications to metadata before saving
180
+ before_save :sync_metadata_cache
181
+
145
182
  # Get metadata with indifferent access (string/symbol keys)
183
+ # Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
146
184
  def metadata
147
185
  @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
148
186
  end
@@ -153,8 +191,25 @@ module UsageCredits
153
191
  super(hash.is_a?(Hash) ? hash.to_h : {})
154
192
  end
155
193
 
194
+ # Clear metadata cache on reload to ensure fresh data from database
195
+ def reload(*)
196
+ @indifferent_metadata = nil
197
+ super
198
+ end
199
+
156
200
  private
157
201
 
202
+ # Sync in-place modifications to the cached metadata back to the attribute
203
+ # This ensures changes like `metadata["key"] = "value"` are persisted on save
204
+ # Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
205
+ def sync_metadata_cache
206
+ if @indifferent_metadata
207
+ write_attribute(:metadata, @indifferent_metadata.to_h)
208
+ elsif read_attribute(:metadata).nil?
209
+ write_attribute(:metadata, {})
210
+ end
211
+ end
212
+
158
213
  # Format operation charge descriptions (e.g., "Process Video (-10 credits)")
159
214
  def operation_description
160
215
  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.5.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.5.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-03-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: pay