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: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [1.0.0] - 2026-03-18

- Rebuild `usage_credits` on top of the new `wallets` ledger core while preserving the existing credits-focused DX
- Add the pre-1.0 upgrade generator for existing installs, including `asset_code`, `bigint` value columns, and transfer support in the underlying wallet layer
- Keep `usage_credits` single-asset and backwards-compatible while allowing advanced wallet-level operations through `credit_wallet`

## [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
79 changes: 66 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 Down Expand Up @@ -97,6 +97,12 @@ rails generate usage_credits:install
rails db:migrate
```

If you're upgrading an existing app from pre-1.0 `usage_credits`, run:
```bash
rails generate usage_credits:upgrade
rails db:migrate
```

Add `has_credits` your user model (or any model that needs to have credits):
```ruby
class User < ApplicationRecord
Expand Down Expand Up @@ -629,9 +635,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 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

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.
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 +743,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 +792,17 @@ 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**. All amounts are stored as integers (for money, usually cents) to avoid floating-point issues.

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
If you need one wallet per currency or asset per user, use [`wallets`](https://github.com/rameerez/wallets):

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.
```ruby
user.wallet(:eur)
user.wallet(:usd)
user.wallet(:wood)
```

That is now the dedicated gem for multi-asset support.

### Naming your "credits"

Expand Down Expand Up @@ -862,7 +913,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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

class UpgradeUsageCreditsToWalletsCore < ActiveRecord::Migration<%= migration_version %>
def up
primary_key_type, foreign_key_type = primary_and_foreign_key_types

# Add asset_code to wallets (default to "credits" for backwards compatibility)
add_column :usage_credits_wallets, :asset_code, :string, null: false, default: "credits"
add_index :usage_credits_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset"

# Change integer columns to bigint for larger balance support
# Note: This is a potentially slow operation on large tables
change_column :usage_credits_wallets, :balance, :bigint, null: false, default: 0
change_column :usage_credits_transactions, :amount, :bigint, null: false
change_column :usage_credits_allocations, :amount, :bigint, null: false
change_column :usage_credits_fulfillments, :credits_last_fulfillment, :bigint, null: false

# Create transfers table for wallet-to-wallet transfers
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

add_index :usage_credits_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_usage_credits_transfers_on_wallets_and_asset"

# Add transfer reference to transactions
add_reference :usage_credits_transactions, :transfer, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transfers }
end

private

def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[primary_key_type, foreign_key_type]
end

def json_column_type
return :jsonb if connection.adapter_name.downcase.include?("postgresql")
:json
end

def json_column_default
return nil if connection.adapter_name.downcase.include?("mysql")
{}
end
end
42 changes: 42 additions & 0 deletions lib/generators/usage_credits/upgrade_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

require "rails/generators/base"
require "rails/generators/active_record"

module UsageCredits
module Generators
class UpgradeGenerator < Rails::Generators::Base
include ActiveRecord::Generators::Migration

source_root File.expand_path("templates", __dir__)

def self.next_migration_number(dir)
ActiveRecord::Generators::Base.next_migration_number(dir)
end

def create_migration_file
migration_template "upgrade_usage_credits_to_wallets_core.rb.erb", File.join(db_migrate_path, "upgrade_usage_credits_to_wallets_core.rb")
end

def display_post_upgrade_message
say "\nUsageCredits 1.0 upgrade migration has been generated!", :green
say "\nThis migration will:"
say " - Add 'asset_code' column to wallets (default: 'credits')"
say " - Change integer columns to bigint for larger balance support"
say " - Create 'usage_credits_transfers' table for wallet transfers"
say " - Add 'transfer_id' column to transactions"
say " - Upgrade pre-1.0 installs to the wallets-backed ledger core"
say "\nTo complete the upgrade:"
say " 1. Review the migration file in db/migrate/"
say " 2. Run 'rails db:migrate'"
say "\n"
end

private

def migration_version
"[#{ActiveRecord::VERSION::STRING.to_f}]"
end
end
end
end
4 changes: 4 additions & 0 deletions lib/usage_credits.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "rails"
require "active_record"
require "pay"
require "wallets"
require "active_support/all"

# Load order matters! Dependencies are loaded in this specific order:
Expand Down Expand Up @@ -43,9 +44,11 @@ class ApplicationJob < ActiveJob::Base
end

# 6. Models (order matters for dependencies)
# These extend Wallets::* classes, so wallets gem must be loaded first
require "usage_credits/models/wallet"
require "usage_credits/models/transaction"
require "usage_credits/models/allocation"
require "usage_credits/models/transfer"
require "usage_credits/models/operation"
require "usage_credits/models/fulfillment"
require "usage_credits/models/credit_pack"
Expand All @@ -62,6 +65,7 @@ module UsageCredits
class Error < StandardError; end
class InsufficientCredits < Error; end
class InvalidOperation < Error; end
class InvalidTransfer < Error; end

class << self
attr_writer :configuration
Expand Down
6 changes: 5 additions & 1 deletion lib/usage_credits/callbacks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ module Callbacks
# @param context_data [Hash] Data to pass to the callback via CallbackContext
def dispatch(event, **context_data)
config = UsageCredits.configuration
callback = config.public_send(:"on_#{event}_callback")
callback_method = :"on_#{event}_callback"

return unless config.respond_to?(callback_method)

callback = config.public_send(callback_method)

return unless callback.is_a?(Proc)

Expand Down
6 changes: 6 additions & 0 deletions lib/usage_credits/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ class Configuration
# Custom transaction categories that extend the default set
attr_reader :additional_categories

# Table prefix for usage_credits tables (for wallets gem compatibility)
# Note: usage_credits uses fixed table names, so this is always "usage_credits_"
def table_prefix
"usage_credits_"
end

# Minimum allowed fulfillment period for subscription plans.
# Defaults to 1.day to prevent accidental 1-second refill loops in production.
# Can be set to shorter periods (e.g., 2.seconds) in development/test for faster iteration.
Expand Down
Loading
Loading