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:
My big realization here is that accounts are more than just database objects - an account is actually a column, not an object.
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:
balance_cash
to balance_outgoing
when an outbound transfer is in progress.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
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.
A ledger entry does the following:
BankAccount
or Project
above)balance_optgoing
)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.
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:
balance_cash
account for $10balance_cash
account for $10This all happens atomically in a single transaction.
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
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.