SimpleWallet

A lightweight, drop-in wallet engine for Ruby on Rails apps. simple_wallet adds a balance ledger to any of your ActiveRecord models — users, accounts, organisations — letting you credit, debit, and query balances with minimal setup.

Features

  • Attach a wallet to any ActiveRecord model with a few lines of code
  • Credit and debit operations backed by a double-entry-style transaction log
  • Query current balance and full transaction history per wallet owner
  • Database-backed — no external services required
  • Rails engine: migrations and models are mounted directly into your app

Requirements

  • Ruby >= 3.0
  • Rails >= 7.0
  • PostgreSQL

Installation

Add the gem to your Gemfile:

gem "simple_wallet"

Install it:

bundle install

Copy and run the migrations:

bin/rails simple_wallet_engine:install:migrations
bin/rails db:migrate db:test:prepare

Usage

Attaching a wallet to a model

bin/rails g migration add_account_to_users simple_wallet_account:references:uniq

Tweak the migration to allow null values (if you have existing records):

class AddAccountToUsers < ActiveRecord::Migration[8.0]
  def change
    add_reference :users, :simple_wallet_account, index: {unique: true}, null: true, foreign_key: true
  end
end

Run migrations:

bin/rails db:migrate db:test:prepare

And finally add Account reference to your model:

class User < ApplicationRecord
  belongs_to :account,
    class_name: "::SimpleWallet::Account",
    optional: true,
    foreign_key: :simple_wallet_account_id

  before_create :create_simple_wallet_account
  
  private
  
  def 
    create_account!
  end
end

Querying the balance

user = User.create(first_name: "Marcin", last_name: "Urbanski")

user.account
=>
  #<SimpleWallet::Account:0x0000710ebe38cac8
    id: 1,
    balance: 0,
    income: 0,
    outcome: 0,
    created_at: "2026-04-11 09:05:41.534180000 -0700",
    updated_at: "2026-04-11 09:05:41.534180000 -0700">

Crediting

SimpleWallet::AccountCreditingService.new(account: user.account, amount: 1_000, source: User.admins.first, note: "Bonus!").credit

user.reload.account
=>
  #<SimpleWallet::Account:0x0000710ebfc29108
    id: 1,
    balance: 1000,
    income: 1000,
    outcome: 0,
    created_at: "2026-04-11 09:11:57.198448000 -0700",
    updated_at: "2026-04-11 09:11:57.198448000 -0700">

Debiting

SimpleWallet::AccountDebitingService.new(account: user.account, amount: 200, source: User.admins.first, note: "AI model invoice").debit

user.reload.account
=>
  #<SimpleWallet::Account:0x0000710ebd85d350
    id: 1,
    balance: 800,
    income: 1000,
    outcome: -200,
    created_at: "2026-04-11 09:11:57.198448000 -0700",
    updated_at: "2026-04-11 09:11:57.198448000 -0700">

Insufficient funds

service = SimpleWallet::AccountDebitingService.new(account: user.account, amount: 1_000_000, source: User.admins.first, note: "AI model invoice")

service.debit
=> false

service.errors.messages
=> {:amount=>["exceeds available balance"]}

# Debit up to account balance:
service = SimpleWallet::AccountDebitingService.new(account: user.account, amount: 1_000_000, source: User.admins.first, note: "AI model invoice", up_to_account_balance: true)

service.debit
=> true

user.reload.account
=>
  #<SimpleWallet::Account:0x0000710ebd8bc080
    id: 5,
    balance: 0,
    income: 1000,
    outcome: -1000,
    created_at: "2026-04-11 09:11:57.198448000 -0700",
    updated_at: "2026-04-11 09:11:57.198448000 -0700">

user.account.transactions
=>
  [
   # ...
   #<SimpleWallet::Transaction::Debit:0x0000710ebd8ba3c0
     id: 2,
     type: "SimpleWallet::Transaction::Debit",
     account_id: 1,
     source_type: "User", # Admin
     source_id: 11,
     pre_account_balance: 800,
     amount: -800, # Debited as much as we could.
     note: "AI model invoice",
     created_at: "2026-04-11 09:27:38.958150000 -0700",
     updated_at: "2026-04-11 09:27:38.958150000 -0700">]

Transfering between accounts

employer = User.create(first_name: "Rich", last_name: "Employer")
employee = User.create(first_name: "Marcin", last_name: "Urbanski")

SimpleWallet::AccountCreditingService.new(account: employer.account, amount: 1_000_000, note: "From venture capitalist").credit
SimpleWallet::TransferService.new(from: employer.reload.account, to: employee.reload.account, amount: 15, note: "We really appreciate your efforts!").transfer

employer.account.transactions.last
=>
  #<SimpleWallet::Transaction::Debit:0x0000710ebf569d90
    id: 6,
    type: "SimpleWallet::Transaction::Debit",
    account_id: 6,
    source_type: nil,
    source_id: nil,
    pre_account_balance: 1000000,
    amount: -15,
    note: "We really appreciate your efforts!",
    created_at: "2026-04-11 09:40:27.018567000 -0700",
    updated_at: "2026-04-11 09:40:27.018567000 -0700">

employee.account.transactions.last
=>
  #<SimpleWallet::Transaction::Credit:0x0000710ebf567f90
    id: 7,
    type: "SimpleWallet::Transaction::Credit",
    account_id: 7,
    source_type: nil,
    source_id: nil,
    pre_account_balance: 0,
    amount: 15,
    note: "We really appreciate your efforts!",
    created_at: "2026-04-11 09:40:27.024512000 -0700",
    updated_at: "2026-04-11 09:40:27.024512000 -0700">

Transaction history

user.account.transactions

=>
  [#<SimpleWallet::Transaction::Credit:0x0000710ebd85a510
    id: 1,
    type: "SimpleWallet::Transaction::Credit",
    account_id: 1,
    source_type: "User", # Admin
    source_id: 11,
    pre_account_balance: 0,
    amount: 1000,
    note: "Bonus!",
    created_at: "2026-04-11 09:12:19.260240000 -0700",
    updated_at: "2026-04-11 09:12:19.260240000 -0700">,
   #<SimpleWallet::Transaction::Debit:0x0000710ebd85a3d0
    id: 2,
    type: "SimpleWallet::Transaction::Debit",
    account_id: 1,
    source_type: "User", # Admin
    source_id: 11,
    pre_account_balance: 1000,
    amount: -200,
    note: "AI model invoice",
    created_at: "2026-04-11 09:15:16.248670000 -0700",
    updated_at: "2026-04-11 09:15:16.248670000 -0700">]

Contributing

Bug reports and pull requests are welcome on GitHub.

  1. Fork the repository
  2. Create a feature branch (git checkout -b my-feature)
  3. Commit your changes (git commit -am 'Add my feature')
  4. Push the branch (git push origin my-feature)
  5. Open a Pull Request

Please run rubocop before submitting — the project ships with a .rubocop.yml.

License

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