usage_credits 0.4.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: 6947ef76d1f3674d94e3fbff6f4149daff82f0e90e55365c485f2e6eef88b319
4
- data.tar.gz: 784a9c5fb5ca6b139bf04bab6f85ddff1d96a15e30cd57f7d8b2f9f167f0aac6
3
+ metadata.gz: 707a34fdb4e9cfa1ca2cb0ccd128baf514c3cc68512afdd93706c288172f296f
4
+ data.tar.gz: 847ecdc0ffef49fe9b6a948dc6d199fa88ab1ea0d4c5486b26ff5b55deb4838c
5
5
  SHA512:
6
- metadata.gz: 54d40b947cda3d9da7932bf1da5c8f8bb97c68c359aa087c8913c2e18ca8bab9500d257c275d02070bcbf5122a4550c83b37205a76080a2db7c598f87366a9d9
7
- data.tar.gz: 8d6e8689d9271526b768472fb8af5ee74caab0dfdb729750d38d0168eb6cf4e6f3f9a0daf034bfbae048a619c38cd8f19c5c2d3b821a797bcbb1db99d85267d2
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,7 @@
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
+
1
5
  ## [0.4.0] - 2026-01-16
2
6
 
3
7
  - Add `balance_before` and `balance_after` to transactions by @rameerez (h/t @yshmarov) in https://github.com/rameerez/usage_credits/pull/27
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
 
@@ -627,6 +629,146 @@ Which will get you:
627
629
 
628
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.
629
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
+
630
772
  ## Demo Rails app
631
773
 
632
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.
@@ -93,4 +93,4 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
93
93
  return nil if connection.adapter_name.downcase.include?('mysql')
94
94
  {}
95
95
  end
96
- 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
  # =========================================
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UsageCredits
4
- VERSION = "0.4.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.4.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-16 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