Accountable: Succinct Ruby Ledger Engine
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 to balance_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:
Users can also invest in crowd-fund projects:
Ledger::Accountable is a concern that lets models keep account sub-columns:
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:
- Points to something that's Accountable (example: BankAccount or Project 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.
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:
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.
What about if the balances don't tie out?
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.