πΌ wallets - Add user wallets with money-like balances to your Rails app
[!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!
Allow your users to have wallets with money-like balances for value holding and transfering. wallets gives any Rails model money-like wallets backed by an append-only transaction ledger. You can use these wallets to store and transfer value in any "currency" (points inside your app, call minutes, in-game resources, in-app assets, etc.)

Use it for:
- Rewards & loyalty points: Cashback, points, store credit, referral bonuses
- Marketplace balances: Seller earnings, buyer credits, platform payouts
- Gig economy: Driver earnings, rider credits, tip wallets
- Multi-currency balances: EUR, USD, GBP wallets per user
- Game resources: Wood, stone, gems, gold, energy; any virtual economy
- Telecom / SIM data plans: "This plan gives you 10 GB per month, transfer unused data to friends"
At its core, wallets provides your users with: a wallet with balance, a log of transactions, expirable balances, and transfers between users.
For example, imagine you're building a SIM card app with data plans. At the beginning of each month, you give your users expirable data and call minutes:
user.wallet(:mb).credit(10_240, expires_at: month_end) # 10 GB in MB
user.wallet(:minutes).credit(500, expires_at: month_end) # 500 call minutes
Users can transfer their unused balance to friends:
user.wallet(:mb).transfer_to(friend.wallet(:mb), 3_072) # Send 3 GB
And balances decrease as they're consumed:
user.wallet(:mb).debit(512, category: :network_usage)
user.wallet(:mb).balance # => 6656 MB remaining
[!TIP] If you want to implement usage credits in your app, use the
usage_creditsgem! It useswalletsunder the hood, and on top provides very handy DX ergonomics for recurring credits fulfillment, credit pack purchases,payintegration for charging users for credits, etc.walletssits at the core of theusage_creditsgem. It's meant to handle a generalized version of any digital in-app currency, not just credits. If you don't know whether you should use thewalletsgem or theusage_creditsgem, check out thewalletsvsusage_creditssection below.
Why this gem
wallets gives you more than users.balance += 1, but less than a full banking system:
| Feature | What it does |
|---|---|
| Multi-asset | One wallet per asset: user.wallet(:usd), user.wallet(:gems) |
| Append-only ledger | Every balance change is a transaction: no edits, only new entries |
| FIFO allocation | Debits consume oldest credits first (important for expiring balances) |
| Linked transfers | Both sides of a transfer are recorded and queryable |
| Row-level locking | Prevents race conditions and double-spending |
| Balance snapshots | Each transaction records before/after balance for reconciliation |
| Rich metadata | Attach any JSON to transactions for audit and filtering |
Quick start
Add the gem to your Gemfile:
gem "wallets"
Then run:
bundle install
rails generate wallets:install
rails db:migrate
Add has_wallets to any model that should own wallets:
class User < ApplicationRecord
has_wallets default_asset: :coins
end
That gives you:
user.wallet # => same as user.main_wallet
user.main_wallet # => wallet(:coins)
user.wallet(:coins).credit(100, category: :reward)
user.wallet(:coins).debit(25, category: :purchase)
user.wallet(:wood).credit(20, category: :quest_reward)
user.wallet(:gems).credit(5, category: :top_up)
Example
Here is a more relatable money flow for a home-cleaning app:
class User < ApplicationRecord
has_wallets default_asset: :eur_cents
end
class Platform < ApplicationRecord
has_wallets default_asset: :eur_cents
end
sara = User.find(1) # customer
lucia = User.find(2) # cleaner
platform = Platform.first
# Sara books a cleaning and tops up her wallet
sara.wallet.credit(10_000, category: :top_up, metadata: { source: "card" })
# Sara books a 2-hour cleaning for β¬60.00
booking_total = 6_000
platform_fee = 900
cleaner_payout = booking_total - platform_fee
# Sara pays the platform the gross booking price
charge = sara.wallet.transfer_to(
platform.wallet,
booking_total,
category: :cleaning_booking_charge,
metadata: { booking_id: 42, cleaner_id: lucia.id }
)
# The app later pays Lucia the net amount and keeps its fee
payout = platform.wallet.transfer_to(
lucia.wallet,
cleaner_payout,
category: :cleaner_payout,
metadata: {
booking_id: 42,
customer_id: sara.id,
gross_transfer_id: charge.id,
platform_fee: platform_fee
}
)
# Query the linked transfer records later
charge.outbound_transaction
payout.inbound_transactions
Amounts are always integers. For money, store the smallest unit like cents. For games, store whole resource units.
API
Owners
class User < ApplicationRecord
has_wallets default_asset: :credits
end
Options:
default_asset:asset returned byuser.walletanduser.main_walletauto_create:whether the main wallet should be created automaticallyinitial_balance:optional starting balance for the auto-created main wallet
Lookup wallets
user.wallet # => default asset wallet
user.main_wallet # => same as user.wallet
user.wallet(:eur) # => auto-creates the EUR wallet if needed
user.wallet?(:gems) # => whether a wallet already exists
user.find_wallet(:usd) # => returns nil instead of auto-creating
Credit and debit
wallet = user.wallet(:gems)
wallet.credit(100, category: :reward)
wallet.debit(20, category: :purchase)
wallet.balance
wallet.history
wallet.has_enough_balance?(50)
Every transaction can carry metadata:
wallet.credit(
500,
category: :top_up,
metadata: { source: "promo_campaign", campaign_id: 12 }
)
Transfers
For internal app payments, transfers are the main primitive:
sender = user.wallet(:eur)
receiver = other_user.wallet(:eur)
transfer = sender.transfer_to(
receiver,
2_000,
category: :peer_payment,
metadata: { message: "Dinner split" }
)
transfer.outbound_transaction
transfer.inbound_transactions
category: on transfer_to describes the business meaning of the transfer itself. The ledger entries created underneath are still recorded as transfer_out and transfer_in, and the original transfer category is mirrored into transaction metadata as transfer_category.
That means you can query transfers directly for business flows:
user.wallet.outgoing_transfers.where(category: :peer_payment)
user.wallet.incoming_transfers.where(category: :peer_payment)
Or query the ledger legs when you need transaction-level audit data:
user.wallet.history
.by_category(:transfer_in)
.where("metadata->>'transfer_category' = ?", "peer_payment")
Transfers require both wallets to use the same asset and the same wallet class. :eur can move to :eur; :wood can move to :wood; Wallets::Wallet cannot transfer directly to UsageCredits::Wallet.
[!NOTE] Transfer expiration behavior: Transfers preserve expiration buckets by default. If a single transfer consumes multiple source buckets with different expirations, the receiver gets multiple inbound credit transactions so those expirations remain intact.
You can override that per transfer:
sender.transfer_to(receiver, 100, expiration_policy: :none) # evergreen on receive sender.transfer_to(receiver, 100, expires_at: 30.days.from_now) # fixed expiration on receive sender.transfer_to(receiver, 100, expiration_policy: :fixed, expires_at: 30.days.from_now)
Negative balances and overdraft
By default wallets rejects any debit or transfer that would push a wallet below zero. Some apps (ride-fare apps with rewards, family wallets with shared spend, telecom plans with bridging credit, etc.) want to allow a small overdraft so the user experience doesn't hard-stop on a low balance.
Flip one flag and it applies consistently to direct wallet.debit and to wallet.transfer_to:
Wallets.configure do |config|
config.allow_negative_balance = true
end
passenger.wallet(:eur).balance # => 100 (1β¬)
passenger.wallet(:eur).transfer_to(driver.wallet(:eur), 300, category: :ride_fare)
passenger.wallet(:eur).reload.balance # => -200 (-2β¬)
driver.wallet(:eur).reload.balance # => 300 (3β¬)
A few things to know:
- Apps own the floor. The flag is binary β it doesn't cap how negative a wallet can go. If you want a "5β¬ convenience overdraft", enforce that in your domain code before calling
transfer_to(e.g. aWalletPolicy.can_afford?(wallet:, amount:)service that checkswallet.balance + MAX_OVERDRAFT >= amount). has_enough_balance?stays strict. It answers "does this wallet have enough on hand right now?" β overdraft is a deliberate choice the caller makes by attempting the debit/transfer, not by querying. Sowallet.has_enough_balance?(amount)returnsfalseeven when the gem would happily complete an overdraft.:preserveexpiration falls back to:nonewhen a transfer goes negative. Withallow_negative_balance = trueand the default:preservepolicy, transfers that exceed the source's positive buckets can't honestly "preserve" an expiration on the deficit portion (there is no source bucket). The transfer'sexpiration_policyis automatically downgraded to:nonefor that transfer; the receiver gets a single evergreen credit. Explicit:fixed(withexpires_at:) and:noneare honored as-is.:balance_depletedfires on positiveβnon-positive crossings, not on exact zero. A debit that takes a wallet from +100 to -50 in one shot still fires the callback. Withallow_negative_balance = falsethe behavior collapses back to "exactly zero" (since balances can't go below zero), so existing callers don't see a change.:low_balance_reachedis one-shot per crossing, regardless of how deep the dip goes. Going from +200 to -100 fires once; the next debit from -100 to -200 does not re-fire because the wallet was already below threshold.:insufficient_balancecallback. Fires only when a debit or transfer is actually rejected (i.e. flag off or your service-layer floor refused). Successful overdraft transfers do not fire it.- Credits don't auto-settle prior debt. If a wallet is at -50 and you
credit(80), the wallet's balance becomes 30 β but the unbacked -50 debit and the +80 credit persist as independent ledger rows. The next debit consumes from the +80 bucket (FIFO) without back-filling the older unbacked debit. The math is consistent; the audit story is "we never settled the original debt with this credit". If you want auto-settlement, do it in your service layer (e.g. when crediting,wallet.transactions.where("amount < 0").where("ABS(amount) > spent_amount") β¦and create allocations explicitly). - System-initiated reversals. Refunds and payout reversals via
transfer_towill also go through, even if the recipient's wallet ends up below zero. That's correct: the ledger has to settle, and a negative wallet records a real debt instead of a silent failure. Apps that want to block user-initiated overdrafts but allow system reversals should keep the floor check in their service layer, not toggle the flag mid-request. - Don't toggle the flag at runtime while wallets are negative.
allow_negative_balanceis meant to be a stable config decision. The model carries abalance >= 0validation gated on the flag; flipping it OFF while wallets sit below zero leaves them un-saveable until you flip it back on (any subsequentcredit/debitcallsrefresh_cached_balance!, which raises aRecordInvalid). If you need to turn the flag off, drain or settle any negative wallets first. - Concurrency holds.
wallet.debit/wallet.creditusewith_lock(row-levelSELECT β¦ FOR UPDATE);wallet.transfer_tolocks both wallets in id order vialock_wallet_pair!. Two concurrent overdraft debits on the same wallet serialize cleanly β they don't double-countprevious_balance.
Expiring balances
Credits can expire:
user.wallet(:coins).credit(
1_000,
category: :season_reward,
expires_at: 30.days.from_now
)
Debits allocate against the oldest available, non-expired credits first.
Configuration
Create or edit config/initializers/wallets.rb:
Wallets.configure do |config|
config.default_asset = :coins
# Useful for direct credit/debit events in your app.
# Transfer business categories live on Wallets::Transfer.category.
config.additional_categories = %w[
quest_reward
provider_bonus
payout
promo_credit
]
config.allow_negative_balance = false
config.low_balance_threshold = 50
config.transfer_expiration_policy = :preserve
end
Callbacks
wallets ships with lifecycle callbacks you can use for notifications, analytics, or product logic.
Wallets.configure do |config|
config.on_balance_credited do |ctx|
Rails.logger.info("Wallet #{ctx.wallet.id} credited by #{ctx.amount}")
end
config.on_balance_debited do |ctx|
Rails.logger.info("Wallet #{ctx.wallet.id} debited by #{ctx.amount}")
end
config.on_transfer_completed do |ctx|
Rails.logger.info("Transfer #{ctx.transfer.id} completed")
end
config.on_low_balance_reached do |ctx|
UserMailer.low_balance(ctx.wallet.owner).deliver_later
end
config.on_insufficient_balance do |ctx|
Rails.logger.warn("Insufficient balance: #{ctx.[:required]}")
end
end
Useful fields on ctx include:
ctx.walletctx.transferctx.amountctx.previous_balancectx.new_balancectx.transactionctx.categoryctx.metadata
wallets vs usage_credits β which gem do I need?
Both gems handle balances, but they solve different problems:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β usage_credits β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Subscriptions, Credit Packs, Pay Intgration, Fulfillment β β
β β Operations DSL, Pricing, Refunds, Webhook Handling β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β wallets β β
β β Balance, Credit, Debit, Transfer, Expiration, FIFO, β β
β β Audit Trail, Row-Level Locking, Multi-Asset β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Aspect | wallets |
usage_credits |
|---|---|---|
| Core job | Store and move value | Sell and consume value |
| Balance model | Multi-asset (:gb, :eur, :gems) |
Single asset (credits) |
| Consumption | Passive β balance depletes over time | Active β spend_credits_on(:operation) |
| Transfers | Built-in between users | Not designed for this |
| Subscriptions | You handle externally | Built-in with Stripe via pay |
| Operations DSL | None | operation :send_email { costs 1.credit } |
| Best for | B2C: games, telecom, rewards, marketplaces | B2B: SaaS, APIs, AI apps |
When to use wallets alone
Use wallets directly when your product:
- Needs multiple asset types β
user.wallet(:wood),user.wallet(:gold),user.wallet(:eur) - Has passive consumption β balance depletes from usage over time (data, minutes, energy)
- Needs user-to-user transfers β gifting, P2P payments, marketplace settlements
- Manages its own subscription logic β or doesn't need subscriptions at all
When to use usage_credits
Use usage_credits when your product:
- Sells credits for specific operations β "Process image costs 10 credits"
- Needs Stripe subscriptions with automatic credit fulfillment
- Wants the operations DSL β
spend_credits_on(:generate_report) - Is a B2B/SaaS/API product with usage-based pricing
When to use both together
For products like a SIM/telecom app, you might use both:
# usage_credits handles ACQUISITION (how users get balance)
subscription_plan :basic_data do
stripe_price "price_xyz"
gives 10_000.credits.every(:month) # 10 GB in MB
end
# wallet-level movement is still available underneath usage_credits
user.credit_wallet.transfer_to(friend.credit_wallet, 3_000) # Gift 3 GB
user.credit_wallet.balance # => 7000 MB remaining
[!TIP]
usage_creditsuseswalletsas its ledger core. If you only needusage_credits, you getwalletsfor free underneath. Wallet-level methods likeuser.credit_wallet.transfer_to(...)are still available there, but the transfer DX intentionally lives at the wallet layer rather than the credits DSL.
Real-world examples
Telecom / Mobile data app
A SIM card app where users get monthly data and can transfer unused GBs to friends:
class User < ApplicationRecord
has_wallets default_asset: :data_mb # Store in MB for precision
end
# Monthly plan grants 10 GB (stored as 10,240 MB)
user.wallet(:data_mb).credit(
10_240,
category: :monthly_plan,
expires_at: 1.month.from_now,
metadata: { plan: "basic", period: "2024-03" }
)
# Network usage consumes data passively
user.wallet(:data_mb).debit(512, category: :network_usage)
# User transfers 3 GB to a friend
user.wallet(:data_mb).transfer_to(
friend.wallet(:data_mb),
3_072,
category: :gift,
metadata: { message: "Here's some extra data!" }
)
user.wallet(:data_mb).balance # => 6656 MB (6.5 GB remaining)
[!NOTE] Store data in the smallest practical unit (MB or KB, not GB as a float).
walletsuses integers to avoid floating-point issues.
Game economy
A farming/strategy game with multiple resources:
class Player < ApplicationRecord
has_wallets default_asset: :gold
end
# Quest rewards multiple resources
player.wallet(:wood).credit(100, category: :quest_reward, metadata: { quest: "forest_patrol" })
player.wallet(:stone).credit(50, category: :quest_reward)
player.wallet(:gold).credit(25, category: :quest_reward)
# Crafting consumes resources
player.wallet(:wood).debit(30, category: :crafting, metadata: { item: "wooden_sword" })
# Premium currency from in-app purchase
player.wallet(:gems).credit(500, category: :purchase, metadata: { sku: "gem_pack_500" })
# Seasonal event with expiring currency
player.wallet(:snowflakes).credit(
1_000,
category: :event_reward,
expires_at: Date.new(2024, 1, 7) # Winter event ends
)
# Trading between players
player.wallet(:gold).transfer_to(
other_player.wallet(:gold),
100,
category: :trade,
metadata: { item_received: "rare_armor" }
)
Home services booking app
A home-services app where the platform collects the customer payment, keeps its fee, and later pays the provider:
class User < ApplicationRecord
has_wallets default_asset: :usd_cents
end
class Marketplace < ApplicationRecord
has_wallets default_asset: :usd_cents
end
sara = User.find(1) # customer
lucia = User.find(2) # cleaner
booking = Booking.find(42)
marketplace = Marketplace.first
# Booking completed
booking_total = 5000 # $50.00
platform_fee = (booking_total * 0.10).to_i # 10%
provider_earnings = booking_total - platform_fee
# Customer pays the marketplace
charge = sara.wallet(:usd_cents).transfer_to(
marketplace.wallet(:usd_cents),
booking_total,
category: :booking_charge,
metadata: {
booking_id: booking.id,
provider_id: lucia.id
}
)
# Marketplace pays the cleaner the net amount
payout = marketplace.wallet(:usd_cents).transfer_to(
lucia.wallet(:usd_cents),
provider_earnings,
category: :provider_payout,
metadata: {
booking_id: booking.id,
gross_amount: booking_total,
platform_fee: platform_fee,
customer_id: sara.id,
booking_charge_transfer_id: charge.id
}
)
# Cleaner later cashes out to Stripe
lucia.wallet(:usd_cents).debit(
lucia.wallet(:usd_cents).balance,
category: :payout,
metadata: { stripe_transfer_id: "tr_xxx" }
)
Loyalty programs & Reward points
Whether you're building a Starbucks-style loyalty program, credit card rewards, airline miles, or a Sweatcoin-style earn-from-actions app β it's the same pattern:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Loyalty program flow β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β EARN β Purchase, action, referral, promo β
β HOLD β Points accumulate, some may expire β
β TRANSFER β Gift to family, pool with friends β
β REDEEM β Rewards, discounts, gift cards β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class User < ApplicationRecord
has_wallets default_asset: :points
end
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# EARN β from purchases, actions, referrals
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Points from purchase (1 point per dollar)
user.wallet(:points).credit(
order.total_cents / 100,
category: :purchase,
metadata: { order_id: order.id }
)
# Bonus points for specific products
user.wallet(:points).credit(150, category: :bonus_item, metadata: { sku: "featured_product" })
# Referral bonus
user.wallet(:points).credit(500, category: :referral, metadata: { referred_user_id: friend.id })
# Daily check-in streaks
user.wallet(:points).credit(50 * streak_multiplier, category: :daily_checkin)
# Receipt scanning (Ibotta-style)
user.wallet(:points).credit(100, category: :receipt_scan, metadata: { receipt_id: 123 })
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# EXPIRING PROMOS β use-it-or-lose-it campaigns
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Welcome bonus that expires in 30 days
user.wallet(:points).credit(
500,
category: :welcome_bonus,
expires_at: 30.days.from_now
)
# Double points weekend (expires Monday)
user.wallet(:points).credit(
200,
category: :promo,
expires_at: Date.current.next_occurring(:monday),
metadata: { campaign: "double_points_weekend" }
)
# Birthday reward
user.wallet(:points).credit(
1000,
category: :birthday,
expires_at: 1.month.from_now,
metadata: { birthday_year: Date.current.year }
)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# TRANSFER β gift to friends, pool with family
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Gift points to another member
user.wallet(:points).transfer_to(
friend.wallet(:points),
500,
category: :gift,
metadata: { message: "Happy birthday!" }
)
# Family pooling (multiple transfers to a shared account)
family_members.each do |member|
member.wallet(:points).transfer_to(
family_pool.wallet(:points),
member.wallet(:points).balance,
category: :family_pool
)
end
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# REDEEM β rewards, discounts, cash out
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Redeem for a reward
user.wallet(:points).debit(
2500,
category: :redemption,
metadata: { reward: "free_coffee", reward_id: 42 }
)
# Redeem for statement credit / gift card
user.wallet(:points).debit(
10_000,
category: :cash_out,
metadata: { gift_card_code: "XXXX-YYYY", value_cents: 1000 }
)
# Partial redemption with points + cash
points_portion = 500
user.wallet(:points).debit(
points_portion,
category: :partial_redemption,
metadata: { order_id: order.id, points_value_cents: points_portion }
)
Loyalty-specific patterns:
| Pattern | Implementation |
|---|---|
| Tiered earning | credit(amount * tier_multiplier, ...) |
| Points expiration | expires_at: 1.year.from_now |
| Family pooling | transfer_to family wallet |
| Gifting | transfer_to friend's wallet |
| Earn + burn in one transaction | debit points, credit new promo points |
| Points + cash | debit points portion, charge card for remainder |
Real-world examples this pattern fits:
- Starbucks Stars
- Airline miles (Delta SkyMiles, United MileagePlus)
- Credit card points (Chase Ultimate Rewards, Amex MR)
- Hotel points (Marriott Bonvoy, Hilton Honors)
- Retail loyalty (Sephora Beauty Insider, REI Co-op)
- Cashback apps (Rakuten, Ibotta, Fetch)
- Fitness rewards (Sweatcoin, Stepn)
Gig economy / Driver earnings
An Uber/DoorDash-style app with earnings and tips:
class Driver < ApplicationRecord
has_wallets default_asset: :usd_cents
end
# Ride completed
driver.wallet(:usd_cents).credit(
1250, # $12.50 base fare
category: :ride_fare,
metadata: { ride_id: ride.id, distance_miles: 5.2 }
)
# Tip added later
driver.wallet(:usd_cents).credit(
300, # $3.00 tip
category: :tip,
metadata: { ride_id: ride.id, rider_id: rider.id }
)
# Weekly payout
driver.wallet(:usd_cents).debit(
driver.wallet(:usd_cents).balance,
category: :weekly_payout,
metadata: { payout_date: Date.current, bank_account: "****1234" }
)
Perfect use cases
wallets is best for closed-loop value inside your app β where the app itself is the source of truth.
| Use case | Example | Why wallets fits |
|---|---|---|
| Telecom / data plans | Mobile data that users can share | Multi-asset (:data_mb, :sms, :minutes), transfers, expiration |
| Game economies | FarmVille, Fortnite, OGame | Multiple resources, trading between players |
| Marketplaces | Etsy, Fiverr, Airbnb | Seller earnings, buyer credits, platform settlements |
| Rewards / loyalty | Sweatcoin, credit card points | Points from actions, expiring promos, redemptions |
| Gig economy | Uber, DoorDash | Driver earnings, tips, scheduled payouts |
| Multi-currency | Travel apps, international platforms | Per-currency wallets (:eur, :usd, :gbp) |
| Store credit | Gift cards, refund credits | Simple balance with full audit trail |
Key signals that wallets is the right fit:
- Users hold multiple types of value (not just one "credits" balance)
- Users transfer value to each other (gifts, trades, P2P payments)
- Value expires (promotional credits, seasonal currencies, data rollovers)
- You need a full audit trail (not just a cached integer)
- The app is the source of truth (not syncing with external ledgers)
When NOT to use wallets
Use usage_credits instead if:
- You're building a SaaS/API product with usage-based pricing
- You need Stripe subscriptions with automatic credit fulfillment
- You want an operations DSL like
spend_credits_on(:generate_report) - Your users buy credits to perform specific actions (not hold transferable balances)
See usage_credits β it uses wallets underneath.
Use something else entirely if:
wallets is the wrong abstraction when the hard part is external money movement, regulation, or accounting-grade settlement:
- Banking infrastructure β transfers to/from bank rails, cards, ACH, SEPA
- Regulated stored-value β KYC, AML, licensing, custody requirements
- Escrow systems β pending, available, reserved, delayed-release states
- FX conversion β multi-currency conversion with exchange rates
- Full accounting β charts of accounts, journal entries, financial reporting
- Blockchain/crypto β consensus, custody, cryptographic guarantees
Skip both gems if:
- You just need one cached integer (
users.balance += 1) and don't care about history, audits, or transfers - Your "balance" is just a counter for display purposes
Rule of thumb:
- "How do I track balances and transfers inside my app?" β
wallets - "How do I sell credits for API/SaaS operations?" β
usage_credits - "How do I build payments infrastructure?" β Neither (you need a banking partner)
Is this production-ready?
Yes, this is production-ready for internal app balances and user-to-user value transfer inside your product. It is substantially more trustworthy than a single integer column because it gives you an append-only ledger, FIFO allocation, linked transfer records, balance snapshots, and row-level locking.
In practice, that means you get:
- a full transaction history instead of just a cached balance
- FIFO consumption of the oldest available balance buckets
- linked debit/credit records for transfers between users
- concurrency protection when multiple writes hit the same wallet
- enough structure to support marketplace balances, peer payments, rewards, and in-game assets inside a real production app
If your product needs users to hold value, earn value, spend value, or transmit value to other users inside your own app, this is the sort of foundation you want instead of users.balance += 1.
Can it support payments between users?
Yes. transfer_to lets you move value between users while keeping both sides of the movement linked in the ledger. That makes it suitable for peer payments, marketplace payouts, seller balances, rewards, and in-game trades inside your own app.
But it is not a blockchain and not a full payments stack.
What it does not do for you:
- external settlement to banks or cards
- KYC/AML/compliance
- escrow, reserves, or held balances
- FX conversion between assets
- disputes, chargebacks, or processor reconciliation
- cryptographic consensus or custody guarantees
So the right framing is: strong internal wallet/accounting primitive, not money infrastructure by itself.
TODO
- First-class transfer reversal/refund API built on compensating ledger entries
- Optional pending/held balance primitives for escrow-like flows
- Multi-step transfer policies beyond
:preserve,:none, and fixedexpires_at
Development
Run the test suite:
bundle exec rake test
Run a specific appraisal:
bundle exec appraisal rails-7.2 rake test
bundle exec appraisal rails-8.1 rake test
License
This project is available as open source under the terms of the MIT License.