Accountable: Succinct Ruby Ledger Engine

Sep 05, 2020

I’ve been thinking about how to represent storage and movements of money for some time now. I’ve seen and worked on several iterations of this in the past, and in this post I’m going to share design principles and an implementation that I think works particularly well.

I used Rails for this implementation for convenience, but the concepts should easily translate to regular Ruby, or even a different language.

There are many flavors of accounting and keeping ledgers, but I think the most important parts are:

  1. Debits & Credits must tie out.
  2. The ledger should be append-only.
  3. The ledger should support sub-accounts. For example, if a user has an incoming wire, their account should be aware of this, but it should also be sophisticated enough to convey to the user that the funds are not yet available.
  4. There should be records of every single movement. Any time funds move from one place to another within the system, you should have some audit trail that you can point to that reflects this movement. And at any point, you should be able to re-create the total system balances using only flow of funds data.
  5. You should be able to make manual adjustments as needed. No system is ever perfect, so you should embrace manual adjustments instead of making something so rigid and assuming it won’t happen.
  6. It shouldn’t require a degree in accounting. It should be simple enough that someone new to the codebase can become comfortable with this engine within a day or so.

My big realization here is that accounts are more than just database objects - an account is actually a column, not an object.

Accounts as Columns

Let’s create this. As a motivating example, we’ll create a bank account model for a user, and in this account, funds can be in one of many sub-accounts:

So, our model would look like this:

class BankAccount < ApplicationRecord
  include Ledger::Accountable

  ledger_account_on :balance_cash, allow_negative_balance: false
  ledger_account_on :balance_outgoing
end

Users can also invest in crowd-fund projects:

class Project < ApplicationRecord
  include Ledger::Accountable

  ledger_account_on :balance_invested
end

*Ledger::Accountable*

Ledger::Accountable is a concern that lets models keep account sub-columns:

module Ledger
  module Accountable
    extend ActiveSupport::Concern

    included do
      has_many :ledger_entries, as: :ledger_accountable
    end

    module ClassMethods
      attr_accessor :ledger

      def ledger_account_on(*args)
        if args[0].is_a?(Symbol) || args[0].is_a?(String)
          field_name = args[0].to_sym
          options = args[1] || {}
        end

        @ledger ||= Concurrent::Map.new
        if @ledger[field_name]
          @ledger[field_name].options = options
        else
          @ledger[field_name] = Ledger::Config.new(field_name, options)
        end
      end
    end
  end
end

In Ledger::Config, you can set whatever handling policies you’d like - example: allowing negative account balances, treating nils as 0, so on.

Ledger Entries

A ledger entry does the following:

  1. Points to something that’s Accountable (example: BankAccount or Project above)
  2. Within that object, points references a specific account column (example: balance_optgoing)
  3. Can be either a debit or credit
  4. Has a certain currency and quantity
  5. Must belong to a Journal entry (more on this in a minute)

Ledger entries are one-way movements of funds. If I give you $10, that’s not one but two entries: for me, a debit for $10, and for you, a credit for $10.

Journals

Great. Now, how do we make sure these ledger entries tie out? It’s sort of like inventing a wrapper around ledger entries.

The only important thing to know about a journal is this: the sum of debits and credits must tie out. If within a block, the journal entry does not net to 0, it should raise an exception, because somewhere, your books won’t balance.

You don’t create journal entries yourself - instead, they’re created as a side effect of calling Journal.record:

my_wallet = BankAccount.find_by(account_owner: 'me')
your_wallet = BankAccount.find_by(account_owner: 'you')
amount = 1000  # $10

Ledger::Journal.record(journal_opts) do |j|
  j.debit(my_wallet, :balance_cash, amount)
  j.credit(your_wallet, :balance_cash, amount)
end

When you call this, several things will happen:

  1. We create a ledger entry debiting my wallet's balance_cash account for $10
  2. We create a ledger entry crediting your wallet's balance_cash account for $10
  3. Since these balances tie out and debits = credits, a journal entry is created, the balance columns are updated, and the transaction gets committed to the database.

This all happens atomically in a single transaction.

Handling Errors

What about if the balances don’t tie out?

context 'with an unbalanced journal' do
    subject do
      proc do
        Ledger::Journal.record(journal_opts) do |j|
          j.debit(my_wallet, :balance_cash, 15)
          j.credit(your_wallet, :balance_donated, 10)
        end
      end
    end

    it 'fails' do
      expect { subject.call }.to(raise_error { Ledger::InvalidTransaction })
    end
  end
end

Why This Way?

  1. You don’t want to over-engineer v1 of your ledger implementation, but you also need to have an upgrade path to deal with more complex cases. Pending balances, outgoing funds, the list goes on. This gets doubly-complicated when you’re thinking about doing this with crypto - a user has a balance, that balance may or may not be staked. The user’s total balance is different from the withdrawable balance, which is different from the staked balance.
  2. There should be records for every logical movement of funds. Whenever funds go from one place to another, there must be a clear record pointing to who did it, when, why, and the calling context. This even works for manual adjustments: they have to come from somewhere, so you should have accounts and records that reflect that.
  3. Your application should be expressive enough so that you can easily keep track of flows of funds. The ledger should be a source of truth, and you can only do this by having something that’s flexible enough for your needs, both right now and also in the future.

Let me know if you’ve run into similar issues - always happy to chat about these types of things. I have a working implementation as a Rails engine, message me if you’d find something like this interesting and I’d be happy to share.