Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ jobs:
ruby-version: ${{ matrix.ruby_version }}
bundler-cache: true

- name: Run tests
run: bundle exec rake test
- name: Prepare database and run tests
# Exercise the real migration path in SQLite too so dummy/test schema
# drift is caught before release, not only in adapter-specific jobs.
run: bundle exec rake db:migrate:reset test

- name: Upload test results
if: failure()
Expand Down
6 changes: 3 additions & 3 deletions Appraisals
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
appraise "rails-7.2" do
gem "rails", "~> 7.2.0"
gem "pay", "~> 11.0"
gem "stripe", "~> 18.0"
gem "stripe", "~> 19.0" # pay >= 11.6 requires stripe ~> 19 at boot
end

# Test latest Rails version (with latest Pay) - this is the default/main Gemfile anyway
appraise "rails-8.1" do
gem "rails", "~> 8.1.0"
gem "pay", "~> 11.0"
gem "stripe", "~> 18.0"
gem "stripe", "~> 19.0" # pay >= 11.6 requires stripe ~> 19 at boot
end

# Test minimum supported Pay version (with latest Rails)
Expand All @@ -38,6 +38,6 @@ end
# Test latest Pay version (with latest Rails)
appraise "pay-11.0" do
gem "pay", "~> 11.0"
gem "stripe", "~> 18.0"
gem "stripe", "~> 19.0" # pay >= 11.6 requires stripe ~> 19 at boot
gem "rails", "~> 8.1.0"
end
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
## [1.0.0] - Unreleased

`usage_credits` is now built on top of [`wallets`](https://github.com/rameerez/wallets), our append-only, multi-asset ledger core. The credits-focused DX you know is unchanged — same `has_credits`, `spend_credits_on`, `give_credits`, packs, subscriptions, and Pay integration — but the FIFO ledger, balance math, row-level locking, and transfer machinery now live in a shared, independently tested core.

### Added

- New runtime dependency: `wallets` (`~> 0.2`), installed automatically with the gem
- Upgrade generator for existing installs: `rails generate usage_credits:upgrade` creates an in-place, re-runnable migration that preserves all existing ledger data. It pre-checks for duplicate owner wallets (possible under pre-1.0's lack of a uniqueness constraint) and aborts with actionable instructions *before* touching the schema if any exist
- Wallet-to-wallet credit transfers via the underlying wallets layer (`usage_credits_transfers` table), with expiration-preserving semantics by default
- `UsageCredits::Transfer` model, plus `transfer_in` / `transfer_out` transaction categories

### Changed

- **Schema** (handled by the upgrade migration for existing apps): wallets gain an `asset_code` column (default `"credits"`); one wallet per owner per asset is now enforced with a unique index; `balance` / `amount` / `credits_last_fulfillment` columns widen from `integer` to `bigint`; transactions gain a nullable `transfer_id` reference
- `UsageCredits::Wallet`, `Transaction`, and `Allocation` now subclass the `wallets` core models via its embeddability hooks (same tables as before, prefixed `usage_credits_`)
- Wallet creation now goes through the race-safe, idempotent `create_for_owner!` from the wallets core; an `initial_balance` is recorded as a proper ledger transaction (category `manual_adjustment`, reason `initial_balance`) instead of a bare column write, so initial balances are auditable

### Unchanged (backwards compatibility)

- The entire public API: `credits`, `credit_history`, `give_credits`, `spend_credits_on`, `has_enough_credits_to?`, `estimate_credits_to`, `add_credits`, `deduct_credits`, callbacks, categories, scopes, and the Pay integration all behave exactly as in 0.5.0
- Negative balances still floor to zero in `credits` (the wallets core can represent overdrafts, but `usage_credits` keeps its historical contract)
- `usage_credits` stays single-asset (`"credits"`) by design — multi-asset apps can use the `wallets` gem directly, side by side, including in the same app

### Upgrade instructions

1. Update the gem, then run `rails generate usage_credits:upgrade`
2. Review the generated migration and **back up your database** (the migration is not reversible)
3. Deploy the gem update and `rails db:migrate` together — the 1.0 models expect the upgraded schema

## [0.5.0] - 2026-03-15

- 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
Expand Down
95 changes: 82 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

`usage_credits` allows your users to have in-app credits / tokens they can use to perform operations.

✨ Perfect for SaaS, AI apps, games, API products, and **marketplace wallets** that want to implement usage-based pricing or track money-like balances.
✨ Perfect for SaaS, AI apps, games, API products, and **single-asset credit systems** that want to implement usage-based pricing or track money-like balances.

> **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.
> **Built on top of [`wallets`](https://github.com/rameerez/wallets).** As of `usage_credits` 1.0, `usage_credits` uses `wallets` as its ledger core underneath. If your main problem is multi-asset wallets, transfers, in-game resources, or general balances, use `wallets` directly. Use `usage_credits` when you want the opinionated DX for credits, operations, subscriptions, packs, and payments.

[ 🟢 [Live interactive demo website](https://usagecredits.com/) ] [ 🎥 [Quick video overview](https://x.com/rameerez/status/1890419563189195260) ]

Expand All @@ -21,6 +21,7 @@ All with a simple DSL that reads just like English.

- An ActiveJob backend (Sidekiq, `solid_queue`, etc.) for subscription credit fulfillment
- [`pay`](https://github.com/pay-rails/pay) gem for Stripe/PayPal/Lemon Squeezy integration (sell credits, refill subscriptions)
- [`wallets`](https://github.com/rameerez/wallets) gem (installed automatically as a dependency — it's the ledger core `usage_credits` runs on)

## 👨‍💻 Example

Expand Down Expand Up @@ -97,6 +98,23 @@ rails generate usage_credits:install
rails db:migrate
```

### Upgrading from pre-1.0

If you're upgrading an existing app from pre-1.0 `usage_credits`, run this instead of the install generator:
```bash
rails generate usage_credits:upgrade
rails db:migrate
```

The upgrade migration moves your existing ledger onto the [`wallets`](https://github.com/rameerez/wallets) core schema **in place** — all balances, transactions, allocations, and fulfillments are preserved exactly as they are. It:

- Adds an `asset_code` column to wallets (defaults to `"credits"`, so nothing changes for you)
- Enforces one wallet per owner with a unique index (it first checks your data and aborts with step-by-step instructions if any owner somehow has duplicate wallets, before touching anything)
- Widens integer amount columns to `bigint`
- Creates the `usage_credits_transfers` table that powers wallet-to-wallet transfers

A few production notes: back up your database first (the migration is not reversible), expect the `bigint` column changes to take a while on very large tables, and deploy the gem update and the migration together (the 1.0 models expect the upgraded schema). Every step of the migration is guarded, so it's safe to re-run if it ever gets interrupted halfway.

Add `has_credits` your user model (or any model that needs to have credits):
```ruby
class User < ApplicationRecord
Expand Down Expand Up @@ -629,9 +647,17 @@ Which will get you:

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.

## Beyond credits: using this gem for money-like wallets and payouts
## Beyond credits: wallet-like balances on top of a credits product layer

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.
While this gem is called `usage_credits`, the underlying architecture is still a **production-grade append-only ledger** with row-level locking, FIFO allocation, and full audit trails. That means you can use it for more than just API credits when the product still fits a **single-asset credits model**.

Good fits here:
- marketplace seller balances in cents
- internal store credit
- cashback / reward points
- telecom-style balances where acquisition/refill matters more than multi-asset modeling

If the real problem is **multi-asset wallets**, **player inventories**, or **wallet-to-wallet transfers as a primary feature**, use [`wallets`](https://github.com/rameerez/wallets) directly instead.

### Custom transaction categories

Expand Down Expand Up @@ -729,9 +755,43 @@ Now you have:
end
```

### Why this works for money
### Wallet-level transfers

The gem's architecture gives you everything you'd need for a money-handling system:
Because `usage_credits` uses `wallets` underneath, the underlying wallet object also supports low-level wallet operations like transfers:

```ruby
seller.credit_wallet.transfer_to(
buyer.credit_wallet,
500,
category: :refund,
metadata: { order_id: 42 }
)
```

Transfers preserve expiration buckets by default because they run through the underlying `wallets` ledger. If you need cash-like behavior instead, you can still opt into evergreen receive-side credits at the wallet layer:

```ruby
seller.credit_wallet.transfer_to(
buyer.credit_wallet,
500,
category: :refund,
expiration_policy: :none,
metadata: { order_id: 42 }
)
```

This is intentionally a **wallet-level API**, not the main `usage_credits` DSL. The main product surface of `usage_credits` is still:
- `give_credits`
- `spend_credits_on`
- credit packs
- subscription fulfillment
- Pay integration

If transfers, multi-asset balances, and wallet movement are central to your app, that is usually a sign you should use [`wallets`](https://github.com/rameerez/wallets) directly.

### Why this still works for money-like balances

The ledger architecture gives you everything you'd want from a serious internal balance system:

| Feature | How it helps |
|---------|--------------|
Expand All @@ -744,14 +804,21 @@ The gem's architecture gives you everything you'd need for a money-handling syst

### A note on multi-currency

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.
`usage_credits` is intentionally **single-asset**: every owner gets exactly one credits wallet (asset code `"credits"`). All amounts are stored as integers (for money, usually cents) to avoid floating-point issues.

If you need one wallet per currency or asset, use [`wallets`](https://github.com/rameerez/wallets) — the dedicated gem for multi-asset support, and the same ledger core `usage_credits` runs on. Both gems coexist cleanly in the same app, each with its own tables. Put `has_wallets` (from `wallets`) on the models that need multi-asset balances, and `has_credits` (from `usage_credits`) on the models that need credits:

If you need multi-currency support, you could:
1. Store amounts in the smallest unit of each currency (cents, pence, etc.)
2. Use metadata to track the currency per transaction
3. Handle conversion at the application layer
```ruby
class User < ApplicationRecord
has_credits # user.credits, user.spend_credits_on(...)
end

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.
class Team < ApplicationRecord
has_wallets # team.wallet(:eur), team.wallet(:usd), team.wallet(:wood)
end
```

One caveat: avoid putting both `has_credits` and `has_wallets` on the *same* model — both define a `wallet` method (the credits wallet vs. the multi-asset lookup), so whichever you include last wins. If you ever do need both on one model, use the unambiguous `credit_wallet` for credits and `find_wallet(:asset)` for the rest.

### Naming your "credits"

Expand Down Expand Up @@ -862,7 +929,9 @@ Real billing systems usually find edge cases when handling things like:
Please help us by contributing to add tests to cover all critical paths!

## TODO
No open TODOs here right now. If you find an edge case, please open an issue or PR.

- Add a first-class reversal/refund helper on top of wallet-level transfers if transfers become a documented primary use case
- Clarify paused subscription behavior across processors and plan states

## Testing

Expand Down
1 change: 1 addition & 0 deletions gemfiles/pay_10.0.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ group :test do
gem "receipts"
gem "sqlite3"
gem "pg"
gem "mysql2"
gem "bootsnap", require: false
gem "puma"
gem "importmap-rails"
Expand Down
3 changes: 2 additions & 1 deletion gemfiles/pay_11.0.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ source "https://rubygems.org"

gem "rake", "~> 13.0"
gem "pay", "~> 11.0"
gem "stripe", "~> 18.0"
gem "stripe", "~> 19.0"
gem "rails", "~> 8.1.0"

group :development do
Expand All @@ -29,6 +29,7 @@ group :test do
gem "receipts"
gem "sqlite3"
gem "pg"
gem "mysql2"
gem "bootsnap", require: false
gem "puma"
gem "importmap-rails"
Expand Down
1 change: 1 addition & 0 deletions gemfiles/pay_8.3.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ group :test do
gem "receipts"
gem "sqlite3"
gem "pg"
gem "mysql2"
gem "bootsnap", require: false
gem "puma"
gem "importmap-rails"
Expand Down
1 change: 1 addition & 0 deletions gemfiles/pay_9.0.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ group :test do
gem "receipts"
gem "sqlite3"
gem "pg"
gem "mysql2"
gem "bootsnap", require: false
gem "puma"
gem "importmap-rails"
Expand Down
3 changes: 2 additions & 1 deletion gemfiles/rails_7.2.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ source "https://rubygems.org"
gem "rake", "~> 13.0"
gem "rails", "~> 7.2.0"
gem "pay", "~> 11.0"
gem "stripe", "~> 18.0"
gem "stripe", "~> 19.0"

group :development do
gem "appraisal"
Expand All @@ -29,6 +29,7 @@ group :test do
gem "receipts"
gem "sqlite3"
gem "pg"
gem "mysql2"
gem "bootsnap", require: false
gem "puma"
gem "importmap-rails"
Expand Down
3 changes: 2 additions & 1 deletion gemfiles/rails_8.1.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ source "https://rubygems.org"
gem "rake", "~> 13.0"
gem "rails", "~> 8.1.0"
gem "pay", "~> 11.0"
gem "stripe", "~> 18.0"
gem "stripe", "~> 19.0"

group :development do
gem "appraisal"
Expand All @@ -29,6 +29,7 @@ group :test do
gem "receipts"
gem "sqlite3"
gem "pg"
gem "mysql2"
gem "bootsnap", require: false
gem "puma"
gem "importmap-rails"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,43 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>

create_table :usage_credits_wallets, id: primary_key_type do |t|
t.references :owner, polymorphic: true, null: false, type: foreign_key_type
t.integer :balance, null: false, default: 0
t.string :asset_code, null: false, default: "credits"
t.bigint :balance, null: false, default: 0
t.send(json_column_type, :metadata, null: false, default: json_column_default)

t.timestamps
end

add_index :usage_credits_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset"

create_table :usage_credits_transfers, id: primary_key_type do |t|
t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets }
t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets }
t.string :asset_code, null: false, default: "credits"
t.bigint :amount, null: false
t.string :category, null: false, default: "transfer"
t.string :expiration_policy, null: false, default: "preserve"
t.send(json_column_type, :metadata, null: false, default: json_column_default)

t.timestamps
end

create_table :usage_credits_transactions, id: primary_key_type do |t|
t.references :wallet, null: false, type: foreign_key_type
t.integer :amount, null: false
t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets }
t.bigint :amount, null: false
t.string :category, null: false
t.datetime :expires_at
t.references :transfer, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transfers }
t.references :fulfillment, type: foreign_key_type
t.send(json_column_type, :metadata, null: false, default: json_column_default)

t.timestamps
end

create_table :usage_credits_fulfillments, id: primary_key_type do |t|
t.references :wallet, null: false, type: foreign_key_type
t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets }
t.references :source, polymorphic: true, type: foreign_key_type
t.integer :credits_last_fulfillment, null: false # Credits given in last fulfillment
t.bigint :credits_last_fulfillment, null: false # Credits given in last fulfillment
t.string :fulfillment_type, null: false # What kind of fulfillment is this? (credit_pack / subscription)
t.datetime :last_fulfilled_at # When last fulfilled
t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed)
Expand All @@ -42,31 +58,32 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
# The "spend" transaction (negative) that is *using* credits
t.references :transaction, null: false, type: foreign_key_type,
foreign_key: { to_table: :usage_credits_transactions },
index: { name: "index_allocations_on_transaction_id" }
index: { name: "index_usage_credits_allocations_on_transaction_id" }

# The "source" transaction (positive) from which the credits are drawn
t.references :source_transaction, null: false, type: foreign_key_type,
foreign_key: { to_table: :usage_credits_transactions },
index: { name: "index_allocations_on_source_transaction_id" }
index: { name: "index_usage_credits_allocations_on_source_tx_id" }

# How many credits were allocated from that particular source
t.integer :amount, null: false
t.bigint :amount, null: false

t.timestamps
end

# Add indexes
# Transaction indexes
add_index :usage_credits_transactions, :category
add_index :usage_credits_transactions, :expires_at
add_index :usage_credits_transactions, [:expires_at, :id], name: "index_usage_credits_transactions_on_expires_at_and_id"
add_index :usage_credits_transactions, [:wallet_id, :amount], name: "index_usage_credits_transactions_on_wallet_id_and_amount"

# Composite index on (expires_at, id) for efficient ordering when calculating balances
add_index :usage_credits_transactions, [:expires_at, :id], name: 'index_transactions_on_expires_at_and_id'

# Index on wallet_id and amount to speed up queries filtering by wallet and positive amounts
add_index :usage_credits_transactions, [:wallet_id, :amount], name: 'index_transactions_on_wallet_id_and_amount'
# Allocation indexes
add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_usage_credits_allocations_on_tx_and_source_tx"

add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx"
# Transfer indexes
add_index :usage_credits_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_usage_credits_transfers_on_wallets_and_asset"

# Fulfillment indexes
add_index :usage_credits_fulfillments, :next_fulfillment_at
add_index :usage_credits_fulfillments, :fulfillment_type
end
Expand Down
Loading
Loading