Join us for a live discussion on recent bank failures and the importance of resilient payment systems.Learn more
Note: This post is the second chapter of a broader technical paper on How to Scale a Ledger.
Adding double-entry to financial events
What does it take for a ledger to be immutable and double-entry at the source of financial events? At the most basic level, it means:
- Logging money movement in a consistent double-entry data model every time it happens.
- Reading from this data model every time money amounts are shown to internal and external customers.
All financial events—from credit card authorizations to crypto on-ramps—can be described with three core objects:
We’ll describe each of these models, how some common financial events map into them, and how they are implemented in the Ledgers API. This section focuses on the core fields and won’t describe every feature available in the Ledgers API. See the documentation for a complete reference.
An Account tracks a sum of money, denominated in a currency. Examples of Accounts include a digital wallet, a company’s operating cash, and loan principal.
Money movement between Accounts in a ledger happens instantly. In the real world, however, moving money between bank accounts is always asynchronous. For example, money sent via ACH takes at least a day to show up in a recipient’s bank account. Because real money movement can’t happen instantly, Accounts should be able to report a few different balances:
- Posted balance: the money that has fully settled in an Account.
- Pending balance: the money that’s expected to settle in or out of an Account plus the money that’s already settled.
- Available balance: the money that’s available to send out of an Account. This balance subtracts money that’s expected to leave an Account, but does not include money that’s expected to settle in an Account.
Not all ledgers model posted, pending, and available balances—those separate balances can be modeled as separate Accounts. We believe that application ledgers should have these concepts built into the core data model, however. You can’t model money movement without them, so they should be simple to use.
Let’s follow an example of a credit card to see how these balances are affected by different actions in the Ledgers API.
We report these three balances separately so that the customer knows how much can be spent from an Account at any given time. Depending on the use case and risk tolerance, an app may choose to use each balance for a different purpose. For example, a customer may be limited by their available balance for transfers out of their Account, but their past-due status for a loan may be optimistically determined by their pending balance.
Account balances are never directly modified. Instead, balances change as Entries are written to the Account. Entries are an immutable log of balance changes on an Account. All fields on the Entry are immutable, except for discarded_at, which is set when an Entry is replaced by a later Entry.
Let’s follow the same credit card example to see how each action can be represented as an Entry.
1. A credit card starts with a $100 credit limit on the account.
2. A card is swiped to purchase a $10 pizza. This purchase starts out pending on the card account.
3. That night, the purchase settles.
4. The card holder initiates a $10 card payment from their bank account.
5. The card payment from the bank account completes.
6. A hotel places a $50 hold on the card at the beginning of a stay.
7. The $50 hold is removed at the end of the stay.
As we showed through the above example, Entries have a mutable field discarded_at that gets set when they get replaced by a newer Entry. Only pending Entries can be replaced; posted and archived Entries are never modified. Pending Entries get replaced in the following two circumstances:
- The pending amount of an Entry needs to change.
- The state of the Entry needs to change.
Why introduce this mutability in the API? To see why, it helps to consider the alternative. Instead of discarding Entries, we could create a reversal Entry that undoes the original Entry. In this model, moving an Entry from pending to posted would create two Entries instead of just one. Step 5 above would instead be:
While valid, this approach leads to a messy Account history. We can’t distinguish between debits that were client-initiated and debits that are generated by the ledger as reversals. Listing Entries on an Account doesn’t match our intuition for what Entries actually comprise the current balance of the Account.
Discarding solves this problem by making it easy to see the current Entries (simply exclude any Entries that have discarded_at set), while also not losing any history.
Computing Account balances
Now that all balance changes are logged as Entries, how do we compute Account balances? Here’s where the normal_balance field on Account comes into play. Every Account in a ledger is categorized as debit normal or credit normal. Definitionally, Accounts that represent uses of funds (assets, expenses) are debit normal, and sources of funds (liabilities, equity, revenue) are credit normal.
The balances of credit normal Accounts are increased by credit Entries and decreased by debit Entries; debit normal Account balances are increased by debit Entries and decreased by credit Entries.
Why bother with debits, credits, and Account normality at all? Many ledgers try to avoid complications by using negative numbers to represent debits and positive numbers to represent credits. At first glance, this appears to align better with engineers’ intuitions.
For Modern Treasury’s Ledgers API, we chose to include the credits and debits concepts because, without them, double-entry accounting is messy. Consider a simple flow where a user deposits money into a digital wallet. This flow will affect the company’s cash Account and the user’s wallet Account. Our intuition is that the cash Account will increase (the company got cash from the user), and also the wallet Account will increase (the user now has a positive balance in the digital wallet).
With a positive/negative number approach, both Accounts increasing is not possible. We have to pick one Account to be negative (so that no money is created or destroyed), and it’s not clear which one.
Credits and debits solve this problem. We should debit the cash Account, whose balance increases because it is debit normal. And we should credit the user's wallet Account, whose balance also increases because it is credit normal.
This digital wallet scenario is just one example. For a full primer on double-entry accounting, check out our series on Accounting for Developers.
Here, we’ll focus on how to actually implement a double-entry ledger.
With some simple math, each type of balance (pending, posted, and available) can be calculated from the following 5 fields:
posted_debits: Sum of posted debit Entries
posted_credits: Sum of posted credit Entries
pending_debits: posted_debits plus the sum of non-discarded pending debit Entries
pending_credits: posted_credits plus the sum of non-discarded pending credit Entries
normal_balance: One of credit or debit, stored on the Account
A ledger database should be optimized to retrieve these 5 fields quickly, and only compute posted balance, pending balance, and available balance upon request.
Each balance type is then computed as follows:
1case normal_balance 2when "credit" 3 posted_credits - posted_debits 4when "debit" 5 posted_debits - posted_credits 6end
1case normal_balance 2when "credit" 3 pending_credits - pending_debits 4when "debit" 5 pending_debits - pending_credits 6end
1case normal_balance 2when "credit" 3 posted_credits - pending_debits 4when "debit" 5 posted_debits - pending_credits 6end
This is the second chapter of a broader technical paper with a more comprehensive overview of the importance of solid ledgering and the amount of investment it takes to get right. If you want to learn more, download the paper, or get in touch.