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 +4 -4
- data/.simplecov +26 -37
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +4 -0
- data/README.md +144 -2
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +1 -1
- data/lib/usage_credits/configuration.rb +18 -0
- data/lib/usage_credits/models/transaction.rb +13 -3
- 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: 707a34fdb4e9cfa1ca2cb0ccd128baf514c3cc68512afdd93706c288172f296f
|
|
4
|
+
data.tar.gz: 847ecdc0ffef49fe9b6a948dc6d199fa88ab1ea0d4c5486b26ff5b55deb4838c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
[](https://badge.fury.io/rb/usage_credits) [](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,
|
|
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.
|
|
@@ -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
|
-
#
|
|
19
|
-
|
|
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:
|
|
72
|
+
validates :category, presence: true, inclusion: { in: ->(record) { Transaction.categories } }
|
|
63
73
|
|
|
64
74
|
validate :remaining_amount_cannot_be_negative
|
|
65
75
|
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-03-15 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: pay
|