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:
Debits & Credits must tie out.
The ledger should be append-only.
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.
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.
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.
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:
- Cash: available for investment
- Outgoing: funds that are accounted for because they are being spent on something. Oftentimes it takes a few days for
funds to move, so we'll move them from
balance_cash
tobalance_outgoing
when an outbound transfer is in progress. - Staked: if a user has staked funds, they technically still belong to the user but they have to be converted into cash before they're fully liquid again.
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:
- Points to something that's Accountable (example:
BankAccount
orProject
above) - Within that object, points references a specific account column (example:
balance_optgoing
) - Can be either a debit or credit
- Has a certain currency and quantity
- 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:
- We create a ledger entry debiting my wallet's
balance_cash
account for $10 - We create a ledger entry crediting your wallet's
balance_cash
account for $10 - 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?
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.
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.
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.