Skip to content

Repository files navigation

💳✨ usage_credits - Add usage-based credits to your Rails app

Gem Version Build Status

Tip

🚀 Ship your next Rails app 10x faster! I've built RailsFast, 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!

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 single-asset credit systems that want to implement usage-based pricing or track money-like balances.

Built on top of 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 ] [ 🎥 Quick video overview ]

Refill user credits with Stripe subscriptions, allow your users to top up by purchasing booster credit packs at any time, rollover unused credits to the next billing period, expire credits, implement PAYG (pay-as-you-go) billing, award free credits as bonuses (for referrals, giving feedback, etc.), get a detailed history and audit trail of every transaction for billing / reporting, and more!

All with a simple DSL that reads just like English.

Requirements

  • Ruby 3.2+ and Rails 7.2.3.1–8.x. Ruby 3.2 is the security floor because current patched versions of transitive Rails dependencies no longer support Ruby 3.1.
  • An ActiveJob backend (Sidekiq, solid_queue, etc.) for subscription credit fulfillment
  • pay 11.6.2–11.x for payment integration. Pay 11.6.2 is the security floor because it fixes GHSA-mjgf-xj26-9qf9.
  • wallets 0.4.2 or any later 0.4.x (installed automatically as the ledger core)

👨‍💻 Example

usage_credits allows you to add credits to your Rails app in just one line of code. If you have a User model, just add has_credits to it and you're ready to go:

class User
  has_credits
end

With that, your users automatically get all credits functionality, and you can start performing operations:

@user.give_credits(100)

You can check any user's balance:

@user.credits
=> 100

And spend their credits securely:

@user.spend_credits_on(:send_email) do
  # Perform the actual operation here.
  # No credits will be spent if this block fails.
end

Defining credit-spending operations is as simple as:

operation :send_email do
  costs 1.credit
end

And defining credit-fulfilling subscriptions is really simple too:

subscription_plan :pro do
  gives 1_000.credits.every :month
  unused_credits :rollover # or :expire
end

This gem keeps track of every transaction and its cost + origin, so you can keep a clean audit trail for clear invoicing and reference / auditing purposes:

@user.credit_history.pluck(:category, :amount)
=> [["signup_bonus", 100], ["operation_charge", -1]]

Each transaction stores comprehensive metadata about the action that was performed:

@user.credit_history.last.metadata
=> {"operation"=>"send_email", "cost"=>1, "params"=>{}, "metadata"=>{}, "executed_at"=>"..."}

You can also expire credits, fulfill credits based on Stripe subscriptions, sell one-time booster credit packs, rollover/expire unused credits to the next fulfillment period, and more!

Sounds good? Let's get started!

Quick start

Add the gem to your Gemfile:

gem 'usage_credits'

Then run:

bundle install
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:

rails generate usage_credits:upgrade
rails db:migrate

The upgrade migration moves your existing ledger onto the 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)
  • Adds a ledger_revision counter to wallets, which the wallets core increments on every ledger change (it is how a stale MySQL snapshot is told from a current one)
  • 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 intentionally irreversible; rollback means restoring that backup.
  • Rehearse the migration against a recent production snapshot and measure it before choosing a deployment window.
  • On PostgreSQL, Rails runs the migration in one DDL transaction. The unique-index build, four bigint conversions, constraints, foreign keys, and transfer-table changes therefore hold their acquired table locks until the whole migration commits. Writes to all usage_credits_* ledger tables may be blocked for the combined duration, not only while each individual bigint conversion is executing. Use a maintenance window for a large or write-heavy ledger. A genuinely lower-lock rollout requires a DBA-reviewed, application-specific staged migration (including concurrent indexes and separately validated constraints); do not add disable_ddl_transaction! to the generated migration casually, because that trades atomicity for partial-schema exposure.
  • MySQL DDL commits step by step. Every mutation is independently guarded, so an interrupted run can be fixed and safely re-run.
  • Deploy the gem update and migration together: the 1.0 models expect the upgraded schema.
  • If your app also uses wallets directly, usage_credits 1.0 brings it to 0.4: run rails generate wallets:upgrade and migrate its tables too. wallets 0.4 raises on every Wallets::Wallet balance read until its holds table exists, and on every command until both the holds table and the ledger_revision column exist.
  • Fresh installs use descriptive usage_credits_* index names. Upgrades deliberately preserve safe legacy index names rather than dropping and rebuilding equivalent indexes, so schema dumps from fresh and upgraded databases can differ by index name while remaining structurally equivalent.

Add has_credits your user model (or any model that needs to have credits):

class User < ApplicationRecord
  has_credits
end

Lastly, schedule the UsageCredits::FulfillmentJob to run periodically (we rely on this ActiveJob job to refill credits for subscriptions). For example, with Solid Queue:

# config/recurring.yml

production:
  refill_credits:
    class: UsageCredits::FulfillmentJob
    queue: default
    schedule: every 5 minutes

(Your actual setup for the recurring job may change if you're using Sidekiq or other ActiveJob backend – make sure you set it up right for your specific backend)

Important

This gem requires an ActiveJob backend to handle recurring credit fulfillment. Make sure you have one configured (Sidekiq, solid_queue, etc.) or subscription credits won't be fulfilled.

That's it! Your app now has a credits system. Let's see how to use it.

How it works

usage_credits makes it dead simple to add a usage-based credits system to your Rails app:

  1. Users can get credits by:
  • Purchasing credit packs (e.g., "1000 credits for $49")
  • Having a subscription (e.g., "Pro plan includes 10,000 credits/month")
  • Arbitrary bonuses at any point (completing signup, referring a friend, etc.)
  1. Users spend credits on operations you define:
  • "Sending an email costs 1 credit"
  • "Processing an image costs 10 credits + 1 credit per MB"

First, let's see how to define these credit-consuming operations.

Define credit-consuming operations and set credit costs

Define all your operations and their cost in your config/initializers/usage_credits.rb file.

For example, create a simple operation named send_email that costs 1 credit to perform:

operation :send_email do
  costs 1.credit
end

You can get quite sophisticated in pricing, and define the cost of your operations based on parameters:

operation :process_image do
  costs 10.credits + 1.credit_per(:mb)
end

Note

Credit costs must be whole numbers. Decimals are not allowed to avoid floating-point issues and ensure predictable billing.

1.credit              # ✅ Valid: whole number
10.credits            # ✅ Valid: whole number
1.credits_per(:mb)    # ✅ Valid: whole number rate

0.5.credits           # ❌ Invalid: decimal credits
1.5.credits_per(:mb)  # ❌ Invalid: decimal rate

For variable costs (like per MB), the final cost is rounded according to your configured rounding strategy (defaults to rounding up). For example, with 1.credits_per(:mb), using 2.3 MB will cost 3 credits by default, to avoid undercharging users.

Units and Rounding

For variable costs, you can specify units in different ways:

# Using megabytes
operation :process_image do
  costs 1.credits_per(:mb)  # or :megabytes, :megabyte
end

# Using units
operation :process_items do
  costs 1.credits_per(:units)  # or :unit
end

When using the operation, you can specify the size directly in the unit:

# Direct MB specification
@user.estimate_credits_to(:process_image, mb: 5)  # => 5 credits

# Or using byte size (automatically converted)
@user.estimate_credits_to(:process_image, size: 5.megabytes)  # => 5 credits

You can configure how fractional costs are rounded:

UsageCredits.configure do |config|
  # :ceil (default) - Always round up (2.1 => 3)
  # :floor - Always round down (2.9 => 2)
  # :round - Standard rounding (2.4 => 2, 2.6 => 3)
  config.rounding_strategy = :ceil
end

By default, we round up (:ceil) all credit costs to avoid undercharging. So if an operation costs 1 credit per megabyte, and the user submits a file that's 5.2 megabytes, we'll deduct 6 credits from the user's wallet.

It's also possible to add validations and metadata to your operations:

# With custom validation
operation :generate_ai_response do
  costs 5.credits
  validate ->(params) { params[:prompt].length <= 1000 }, "Prompt too long"
end

# With metadata for better tracking
operation :analyze_data do
  costs 20.credits
  meta category: :analytics, description: "Deep data analysis"
end

Spend credits

There's a handy estimate_credits_to method to can estimate the total cost of an operation before spending any credits:

@user.estimate_credits_to(:process_image, size: 5.megabytes)
=> 15 # (10 base + 5 MB * 1 credit/MB)

There's also a has_enough_credits_to? method to nicely check the user has enough credits before performing a certain operation:

if @user.has_enough_credits_to?(:process_image, size: 5.megabytes)
  # actually spend the credits
else
  redirect_to credits_path, alert: "Not enough credits!"
end

Finally, you can actually spend credits with spend_credits_on:

@user.spend_credits_on(:process_image, size: 5.megabytes)

To ensure credits are not subtracted from users during failed operations, you can pass a block to spend_credits_on. No credits are spent if the block doesn't succeed (it shouldn't raise any exceptions or throw any errors) This way, you ensure credits are only spent if the operation succeeds:

@user.spend_credits_on(:process_image, size: 5.megabytes) do
  process_image(params)  # If this raises an error, no credits are spent
end

The credits are deducted right after the block, inside the same database transaction, so leave the block by finishing it or by raising. A return or break out of the block skips the deduction: the block's own database writes are committed and the operation ends up free.

If you want to spend the credits immediately, you can use the non-block form:

@user.spend_credits_on(:process_image, size: 5.megabytes)
process_image(params)  # If this fails, credits are already spent!

Tip

Always estimate and check credits before performing expensive operations. If validation fails (e.g., file too large), both methods will raise InvalidOperation. Perform your operation inside the spend_credits_on block OR make the credit spend conditional to the actual operation, so users are not charged if the operation fails.

Low balance alerts & other lifecycle callbacks

usage_credits makes it easy to add callbacks for important events.

You can hook into credit events, for example, to upsell credit packs to users when they reach a low balance:

config.on_low_balance_reached do |ctx|
  # Sell your users credits here
  # Example: LowCreditsMailer.buy_more(ctx.owner, remaining: ctx.new_balance).deliver_later
end

Tip

For on_low_balance_reached, define what a low balance is by setting the low_balance_threshold like: config.low_balance_threshold = 100.credits

You configure callbacks in your usage_credits.rb initializer file.

Here are all the available callbacks you can hook into:

  • on_credits_added: After credits are added to a wallet
  • on_credits_deducted: After credits are deducted from a wallet
  • on_low_balance_reached: When balance drops below threshold (fires once per crossing)
  • on_balance_depleted: When balance reaches exactly zero
  • on_insufficient_credits: When an operation fails due to insufficient credits
  • on_credit_pack_purchased: After a credit pack purchase is fulfilled
  • on_subscription_credits_awarded: After subscription credits are awarded

They're useful for analytics, audit logging, notifications, or custom business logic.

Callbacks are isolated, so errors in callbacks won't break credit operations.

Important

Callbacks get executed every single time the action happens (duh) so you don't want to do heavy operations there, or else your app could become extremely slow. Keep callbacks fast: one good option is using the callback to enqueue background jobs (deliver_later, perform_later) to avoid blocking credit operations.

The ctx object in callbacks

All callbacks receive a context object (ctx). The ctx objects contains useful fields like ctx.owner, ctx.amount, etc. Some examples:

UsageCredits.configure do |config|
  # Prompt users to buy more credits when they run out
  config.on_balance_depleted do |ctx|
    # Send a mail here like "You've ran out of credits! Purchase more here!"
    OutOfCreditsMailer.buy_more(ctx.owner).deliver_later
  end

  # Log failed operations (useful for debugging)
  config.on_insufficient_credits do |ctx|
    Rails.logger.info "[Credits] User #{ctx.owner.id} needs #{ctx.amount}, has #{ctx.metadata[:available]}"
  end

  # Track purchases in your analytics (Mixpanel, Amplitude, Segment, etc.)
  config.on_credit_pack_purchased do |ctx|
    # Replace with your analytics service
    # Log something like: "User #{ctx.owner.id} purchased #{ctx.amount} credits"
  end
end

Available ctx fields vary by event:

  • credits_added: ctx.owner, ctx.wallet, ctx.amount, ctx.previous_balance, ctx.new_balance, ctx.transaction, ctx.category, ctx.metadata
  • credits_deducted: ctx.owner, ctx.wallet, ctx.amount, ctx.previous_balance, ctx.new_balance, ctx.transaction, ctx.category, ctx.metadata
  • low_balance_reached: ctx.owner, ctx.wallet, ctx.previous_balance, ctx.new_balance, ctx.threshold
  • balance_depleted: ctx.owner, ctx.wallet, ctx.previous_balance, ctx.new_balance
  • insufficient_credits: ctx.owner, ctx.wallet, ctx.amount, ctx.operation_name, ctx.metadata
  • credit_pack_purchased: ctx.owner, ctx.wallet, ctx.amount, ctx.transaction, ctx.metadata
  • subscription_credits_awarded: ctx.owner, ctx.wallet, ctx.amount, ctx.transaction, ctx.metadata

All contexts support ctx.to_h to convert to a hash (excludes nil values).

Metadata contents:

  • insufficient_credits: { available:, required:, params: }
  • credit_pack_purchased: { credit_pack_name:, credit_pack:, pay_charge:, price_cents: }
  • subscription_credits_awarded: { subscription_plan_name:, subscription:, pay_subscription:, fulfillment_period: }

Award bonus credits

You might want to award bonus credits to your users for arbitrary actions at any point, like referring a friend, completing signup, or any other reason.

To do that, you can just do:

@user.give_credits(100, reason: "referral")

And the user will get the credits with the proper category in the transaction ledger (so bonus credits get differentiated from purchases / subscriptions for audit trail purposes)

Providing a reason for giving credits is entirely optional (it just helps you if you need to use or analyze the audit trail) – if you don't specify any reason, :manual_adjustment is the default reason.

You can also give credits with arbitrary expiration dates:

@user.give_credits(100, expires_at: 1.month.from_now)

So you can expire any batch of credits at any date in the future.

Sell credit packs

Important

For all payment-related operations (sell credit packs, handle subscription-based fulfillment, etc.) this gem relies on the pay gem – make sure you have it installed and correctly configured with your payment processor (Stripe, Lemon Squeezy, PayPal, etc.) before continuing. Follow the pay README for more information and installation instructions.

In the config/initializers/usage_credits.rb file, define credit packs users can buy:

credit_pack :starter do
  gives 1000.credits
  costs 49.dollars
end

Then, you can prompt them to purchase it with our pay-based helpers:

# Create a Stripe Checkout session for purchase
credit_pack = UsageCredits.find_credit_pack(:starter)
session = credit_pack.create_checkout_session(current_user)
redirect_to session.url

The gem automatically handles:

  • Credit pack fulfillment after successful payment
  • Proportional credit removal on refunds: if 50% is refunded, 50% of the credits are removed. A fraction of a credit rounds up, and a repeated or partial refund of one purchase only ever takes the difference still outstanding
  • Prevention of double-processing of purchase
  • Support for multiple currencies (USD, EUR, etc.)
  • Detailed transaction tracking with metadata like:
    {
      credit_pack: "starter",         # Credit pack identifier
      charge_id: "ch_xxx",            # Payment processor charge ID
      processor: "stripe",            # Payment processor used
      price_cents: 4900,              # Amount paid in cents
      currency: "usd",                # Currency used for payment
      credits: 1000,                  # Base credits given
      purchased_at: "2024-01-20"      # Purchase timestamp
    }

Subscription plans that grant credits

Users can subscribe to a plan (monthly, yearly, etc.) that gives them credits.

Defining a subscription plan is as simple as this:

subscription_plan :pro do
  stripe_price "price_XYZ"            # Link it to your Stripe price ID
  gives 10_000.credits.every(:month)  # Monthly credits
  signup_bonus 1_000.credits          # One-time bonus
  trial_includes 500.credits          # Trial period credits
  unused_credits :rollover            # Credits roll over to the next fulfillment period (:rollover or :expire)
  expire_after 30.days                # Optional: credits expire after cancellation
end

Cancellation terms are snapshotted when the subscription is fulfilled, so a later initializer change or plan removal cannot rewrite the customer's policy. expire_after only shortens credits minted by that subscription; manual grants, credit packs, and other subscriptions are untouched. When cancellation makes those credits expire immediately, any resulting low-balance or depleted crossing is dispatched after the cancellation transaction commits.

The first thing to understand is that credit fulfillment is decoupled from billing periods:

Credit fulfillment cycles

Credit fulfillment is completely decoupled from billing periods.

This means you can drip credits at any pace you want (e.g., 100/day instead of 3000/month) – and that's completely independent of when your users get actually charged (typically on a monthly or yearly basis, as you defined on Stripe)

pay handles the user's subscription payments (billing periods), we handle how we fulfill that subscription (fulfilling cycles)

We rely on ActiveJob to fulfill credits. So you should have an ActiveJob backend installed and configured (Sidekiq, solid_queue, etc.) for credits to be refilled. To make fulfillment actually work, you'll need to schedule the fulfillment job to run periodically, as explained in the setup section.

First, create a Stripe subscription

usage_credits relies on you first creating a subscription on your Stripe dashboard and then linking it to the gem by setting the specific Stripe plan ID in the subscription config using the stripe_price option, like this:

subscription_plan :pro do
  stripe_price "price_XYZ"
  # ...
end

For now, only Stripe subscriptions are supported (contribute to the codebase to help us add more payment processors!)

Subscriptions with a monthly + yearly price

For plans that offer multiple billing periods (e.g., monthly and yearly pricing), you can specify multiple Stripe price IDs using a hash:

subscription_plan :pro do
  # Multi-period pricing: same plan, different billing frequencies
  stripe_price month: "price_pro_monthly", year: "price_pro_yearly"

  gives 10_000.credits.every(:month)
  # ...
end

The gem automatically handles plan matching for webhook events - when Stripe sends subscription events, the plan will be correctly identified regardless of which billing period was selected.

Note: Single-price plans (the traditional format) continue to work as before for backward compatibility:

subscription_plan :basic do
  stripe_price "price_basic_monthly"  # Single price ID (still works)
  gives 1_000.credits.every(:month)
end

Using pricing_plans? Declare your prices once

If you also use pricing_plans, your Stripe prices already live in config/initializers/pricing_plans.rb. Don't copy them here: point the subscription plan at its pricing plan instead.

# config/initializers/pricing_plans.rb
plan :pro do
  stripe_price month: "price_pro_monthly", year: "price_pro_yearly", quarter: "price_pro_quarterly"
end

# config/initializers/usage_credits.rb
subscription_plan :pro do
  pricing_plan                        # Take the Stripe prices of pricing_plans' :pro plan
  gives 10_000.credits.every(:month)
  unused_credits :expire
end

Every Stripe price of the pricing plan counts, whatever you named its keys (a single stripe_price "price_x" works too), so a subscriber on any of them gets their credits. Pass a key when the names differ: pricing_plan :pro_2026.

A few things to know:

  • Initializer order doesn't matter. Prices are read from pricing_plans every time a subscription is matched, never copied at boot, so pricing_plans.rb can load before or after usage_credits.rb.
  • One source of truth. A plan that uses pricing_plan can't also declare stripe_price; that raises UsageCredits::ConfigurationError. Keep every price, including legacy ones for grandfathered subscribers, in the pricing plan. pricing_plans up to 0.7.x accepts any key for them, but its next version only accepts id, day, week, month, quarter and year, so there a legacy price needs a spare interval key. Plan IDs for other processors (processor_plan :paddle, ...) still work alongside it.
  • Broken links fail loudly. If pricing_plans isn't installed, or has no plan with that key, your app raises UsageCredits::ConfigurationError at boot (and on any lookup) instead of silently refilling nothing. A pricing plan with no Stripe price in the current environment only logs a warning at boot, since build steps like assets:precompile often run without credentials.
  • pricing_plans stays optional: usage_credits never requires it. The only thing it reads is PricingPlans::Registry.plan(key).stripe_price.

Specify a fulfillment period

Next, specify how many credits a user subscribed to this plan gets, and when they get them.

Since fulfillment cycles are decoupled from billing cycles, you can either match fulfillment cycles to billing cycles (that is, charge your users every month AND fulfill them every month too, to keep things simple) OR you can specify something else like refill credits every :day, every :quarter, every 15.days, every :year etc.

subscription_plan :pro do
  gives 10_000.credits.every(15.days)
  # or, another example:
  # gives 10_000.credits.every(:quarter)
  # ...
end

Expire or rollover unused credits

At the end of the fulfillment cycle, you can either:

  • Expire all unused credits (so the user starts with X fixed amount of credits every period, and all of them expire at the end of the period, whether they've used them or not)
  • Carry unused credits over to the next period

Just set unused_credits to either :expire or :rollover

subscription_plan :pro do
  unused_credits :expire # or :rollover
  # ...
end

Grace period for credit expiration

When credits expire (i.e., unused_credits :expire), there's a configurable grace period to ensure smooth transitions between fulfillment cycles. This prevents users from seeing zero credits between the moment old credits expire and new credits are fulfilled.

UsageCredits.configure do |config|
  # Grace period for credit expiration (default: 5 minutes)
  # Should match the frequency of your UsageCredits::FulfillmentJob runs
  config.fulfillment_grace_period = 5.minutes
end

Note

Automatic grace period capping: If your fulfillment period is shorter than the grace period (e.g., credits every 15 seconds with a 5-minute grace period), the grace period is automatically capped to the fulfillment period. This prevents balance accumulation where credits pile up faster than they expire.

For example, with gives 100.credits.every(15.seconds) and a 5-minute grace period, the effective grace period will be 15 seconds, not 5 minutes. A warning will be logged during initialization to alert you of this behavior.

This is typically only relevant for development/testing with very short fulfillment periods. In production with monthly or daily fulfillment cycles, the default 5-minute grace period works perfectly.

Upgrades, downgrades, and plan changes

usage_credits reacts to plan changes (via the pay gem), and we handle automatically credit issuing for upgrades & downgrades:

  • Upgrades: credits are granted immediately for the new plan. If the new plan expires credits, upgrade credits expire too.
  • Downgrades: scheduled for the end of the current period; users keep current benefits until then.
  • Non-credit plan changes: moving from credit → non-credit stops fulfillment at period end (no clawback).
  • Reactivation: moving back to a credit plan reactivates fulfillment and grants credits (no signup bonus on reactivation).
  • Pending downgrades: if a user returns to their current plan before the downgrade takes effect, we cancel the pending change and do not grant extra credits.
  • Credit gaming prevention: we take measures to protect against user gaming the credit system by repeatedly upgrading/downgrading their subscription.

This happens automatically thanks to our Pay Subscription extension (changes to the Subscription model in the pay gem trigger usage_credits issuing the right credits based on the subscription change)

What we handle vs. what we don't (brief)

Handled:

  • Subscription create, renew, cancel, upgrade, downgrade, non-credit transitions
  • Pending downgrade application on renewal
  • Processor pauses and resumes: no credits are minted during an effective pause; scheduled pauses follow Pay's processor lifecycle semantics; plan changes made while paused are atomically reconciled without a bonus before service resumes minting
  • Credit expiration and rollover

Not handled (yet):

  • Plan changes while trialing (we only handle status == "active")

Transaction history & audit trail

Every transaction (whether adding or deducting credits) is logged in the ledger, and automatically tracked with metadata:

# Get recent activity
user.credit_history.recent

# Filter by type
user.credit_history.by_category(:operation_charge)
user.credit_history.by_category(:subscription_credits)

# Audit operation usage
user.credit_history
  .where(category: :operation_charge)
  .where("metadata->>'operation' = ?", 'process_image')
  .where(created_at: 1.month.ago..)

Each operation charge includes detailed audit metadata:

{
  operation: "process_image",             # Operation name
  cost: 15,                               # Actual cost charged
  params: { size: 1024 },                 # Parameters used
  metadata: { category: "image" },        # Custom metadata
  executed_at: "2024-01-19T16:57:16Z",    # When executed
  gem_version: "1.0.0"                    # Gem version
}

This makes it easy to:

  • Audit operation usage
  • Generate detailed invoices
  • Monitor usage patterns

Running balance (balance after each transaction)

Every transaction automatically tracks the wallet balance before and after it was applied, like you would find in a bank statement:

user.credit_history.each do |tx|
  puts "#{tx.created_at.strftime('%Y-%m-%d')}: #{tx.formatted_amount}"
  puts "  Balance: #{tx.balance_before} → #{tx.balance_after}"
end

# Output:
# 2024-12-16: +1000 credits
#   Balance: 0 → 1000
# 2024-12-26: +500 credits
#   Balance: 1000 → 1500
# 2025-01-14: -50 credits
#   Balance: 1500 → 1450

This is useful for building transaction history UIs, generating statements, or debugging balance issues. Each transaction provides:

transaction.balance_before            # Balance before this transaction
transaction.balance_after             # Balance after this transaction
transaction.formatted_balance_after   # Formatted (e.g., "1450 credits")

balance_before and balance_after return nil if no balance is found (for transactions created before this feature was added)

Custom credit formatting

A minor thing, but if you want to use the @transaction.formatted_amount helper, you can specify the format:

UsageCredits.configure do |config|
  config.format_credits do |amount|
    "#{amount} tokens"
  end
end

Which will get you:

@transaction.formatted_amount
# => "42 tokens"

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: 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-oriented ledger API with row-level locking, expiration-aware allocation, and full transaction 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 directly instead.

Custom transaction categories

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:

# config/initializers/usage_credits.rb
UsageCredits.configure do |config|
  # Add custom categories for your business logic
  config.additional_categories = %w[
    payment_received
    payment_sent
    payout_requested
    platform_fee
    refund
    tip
    cashback
  ]
end

These custom categories work exactly like the built-in ones — they're validated, tracked in transaction history, and available for filtering/querying.

Example: Marketplace with seller payouts

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:

# app/models/user.rb
class User < ApplicationRecord
  has_credits  # Each user gets a wallet

  def request_payout(amount_cents)
    # deduct_credits locks and checks the wallet atomically.
    wallet.deduct_credits(
      amount_cents,
      category: :payout_requested,
      metadata: {
        requested_at: Time.current,
        payout_method: stripe_account_id
      }
    )

    PayoutJob.perform_later(self, amount_cents)
  end
end

# app/services/order_payment_service.rb
class OrderPaymentService
  def initialize(order)
    @order = order
    @buyer = order.buyer
    @seller = order.seller
  end

  def process!
    platform_fee = (@order.total_cents * 0.10).to_i  # 10% fee
    seller_amount = @order.total_cents - platform_fee

    ActiveRecord::Base.transaction do
      # Credit the seller (net of platform fee)
      @seller.wallet.add_credits(
        seller_amount,
        category: :payment_received,
        metadata: {
          order_id: @order.id,
          gross_amount: @order.total_cents,
          platform_fee: platform_fee,
          buyer_id: @buyer.id
        }
      )

      @order.update!(paid: true)
    end
  end
end

Now you have:

  • Full audit trail: Every transaction is logged with metadata
  • Balance tracking: @seller.credits returns current balance in cents
  • Transaction history: @seller.credit_history.by_category(:payment_received)
  • Concurrency safety: Row-level locks prevent double-spending
  • Running balance: Each transaction stores balance_before and balance_after
# Show transaction history with running balance
@seller.credit_history.recent.each do |tx|
  puts "#{tx.created_at.strftime('%Y-%m-%d')}: #{tx.category}"
  puts "  Amount: #{tx.amount} cents"
  puts "  Balance: #{tx.balance_before} → #{tx.balance_after}"
  puts "  Order: ##{tx.metadata['order_id']}" if tx.metadata['order_id']
end

Wallet-level transfers

Because usage_credits uses wallets underneath, the underlying wallet object also supports low-level wallet operations like transfers:

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:

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

transfer_credits_to is a backwards-compatible alias for transfer_to; both accept the same expiration options and return a UsageCredits::Transfer. Transfer domain failures stay inside the usage_credits error hierarchy: UsageCredits::InvalidTransfer for invalid endpoints and UsageCredits::InsufficientCredits for insufficient balance.

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 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
Allocation-backed ledger Every spend records exactly which credit buckets it consumed
Append-oriented operations Public wallet operations record new transaction rows instead of editing balances in place
Row-level locking Prevents race conditions and double-spending
Expiration-aware allocation Soonest-expiring credits are spent first, with oldest-first ties
Balance snapshots Each transaction records balance before/after for reconciliation
Rich metadata Store order IDs, user IDs, payment references — whatever you need for audit

The public API is append-oriented, not tamper-proof: code with model or SQL access can still modify ledger rows, and destroying an owner intentionally cascades through that owner's credit history. Use soft deletion or an application-level destroy restriction when records must be retained, keep database backups, reconcile payment-processor events, and apply the audit controls appropriate to your risk model.

A note on multi-currency

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 — 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:

class User < ApplicationRecord
  has_credits             # user.credits, user.spend_credits_on(...)
end

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"

Remember you can customize how credits are displayed:

UsageCredits.configure do |config|
  config.format_credits do |amount|
    # Display as money
    "$#{(amount / 100.0).round(2)}"
  end
end

@user.credit_history.last.formatted_amount
# => "+$25.00"

Demo Rails app

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.

Technical notes on architecture and how this gem is built

Building a usage credits system is deceptively complex.

The first naive approach is to think this whole thing can be implemented as just a balance attribute in the database, a number that you update whenever the user buys or spends credits.

That results in a plethora of bugs as soon as time starts rolling and customers start upgrading, downgrading, and cancelling subscriptions. Customers won't get what they paid for, and you'll always have problems. You always feel like repairing a leaking budget. So you may be tempted to offload all the credit-fulfilling logic to Stripe webhooks and such.

That only gets you so far.

One problem is the discrepancy between billing periods and fulfillment cycles (you may want to charge your users up front for a whole year if they have a yearly subscription, but you may not want to refill all their credits up front, but month by month). Once you add expiring credits, credit packs, and refunds, you need an allocation-backed transaction ledger that tracks every grant and spend. Writes must be atomic and serialized with row-level locks to prevent double-spending.

That's exactly what I ended up building:

  • Wallet is the root of all functionality. All users have a wallet that centralizes everything and keeps track of the available balance – and all credit operations (add/deduct credits) are performed on the wallet.
  • Transaction - operations get logged as transactions. The Transaction model is the basis for the ledger system.
  • Fulfillment represents a credit-giving action (wether recurring or not). Subscriptions are tied to a Fulfillment record that orchestrates when the actual credit fulfillment should happen, and how often. A Fulfillment object will create one or many positive Transactions.
  • Allocation is the basis for our bucket-based, first-expiring-first-out credit spending system. It's what solves the dragging cost problem and allows for expiring credits.
  • CreditPack and CreditSubscriptionPlan are POROs that model credit-giving objects (one-time purchases for credit packs; recurring subscriptions for subscription plans). They allow for easy configuration through the DSL and store all information on memory.
  • Operation represents a credit-spending operation.

Row-level locks

Heads up: we acquire a row-level lock when spending credits, to avoid concurrency inconsistencies. This means the row will be locked for as long as the credit-spending operation lasts. If the block is short (which 99% of the time it is – like updating a record, sending an email, etc.), you’re golden. If someone tries to do 2 minutes of CPU-bound AI generation under that lock, concurrency for that user’s wallet is blocked. Possibly that’s what we want in any case, but it’s something you should know for large tasks.

Summary of features

Core ledger:

  • Append-oriented wallet operations with a complete transaction trail
  • Row-level locks to prevent double-spending even with concurrent usage
  • Secure credit spending (credits will not be deducted if the operation fails)
  • Audit trail / transaction logs (each transaction has metadata on how the credits were spent, and what "credit bucket" they drew from)
  • Avoids floating-point issues by enforcing integer-only operations

Billing system:

  • Integrates with pay loosely enough not to rely on a single payment processor (we use Pay::Charge and Pay::Subscription model callbacks, not payment-processor-specific webhooks)
  • Handles total and partial refunds
  • Deals with new subscriptions and cancellations
  • One-time credit packs can be bought at any time, independent of subscriptions

Credit fulfillment system:

  • Credits can be fulfilled at arbitrary periods, decoupled from billing cycles
  • Credits can be expired
  • Credits can be rolled over to the next period
  • Prevents double-fulfillment of credits
  • First-expiring-first-out bucket allocation, with oldest-first ties

Numeric extensions

The gem adds several convenient methods to Ruby's Numeric class to make the DSL read naturally:

# Credit amounts
1.credit      # => 1 credit
10.credits    # => 10 credits

# Pricing
49.dollars    # => 4900 cents (for Stripe)
29.euros      # => 2900 cents (for Stripe)
99.cents      # =>   99 cents (for Stripe)

# Sizes and rates
1.credit_per(:mb)     # => 1 credit per megabyte
2.credits_per(:unit)  # => 2 credits per unit
100.megabytes         # => 100 MB (uses Rails' numeric extensions)

Kernel extensions

This gem pollutes a bit the Kernel namespace by defining 3 top-level methods: operation, credit_pack, and credit_subscription. We do this to have a DSL that reads like plain English. I think the benefits of having these methods outweight the downsides, and there's a low chance of name collision, but in any case it's important you know they're there.

Edge cases

Billing systems are extremely complex and full of edge cases. This is a new gem, and it may be missing some edge cases.

Production integrations should still define and test their product policy for things like:

  • Prorated changes
  • Different pricing tiers
  • Usage rollups and aggregation
  • Upgrading and downgrading subscriptions
  • Processor-specific billing/proration modes around subscription transitions
  • Re-activating subscriptions
  • Refunds and credits
  • Failed payments
  • Usage caps

Please help us by contributing to add tests to cover all critical paths!

TODO

  • Add a first-class reversal/refund helper on top of wallet-level transfers if transfers become a documented primary use case

Testing

Run the test suite with bundle exec rake test

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bundle exec rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/usage_credits. Our code of conduct is: just be nice and make your mom proud of what you do and post online.

License

The gem is available as open source under the terms of the MIT License.

Releases

Packages

Used by

Contributors

Languages