Double-entry accounting for engineers

Every serious billing system eventually needs a real ledger, not just a table of payment rows. Double-entry accounting is centuries old, genuinely simple once the core rule clicks, and the actual foundation every "add ledger" feature request is asking for.

Advanced

3 min read

The one rule everything else follows from

Every transaction touches at least two accounts, and the total amount recorded as debits must always equal the total recorded as credits. That's the entire mechanical rule. Nothing else in double-entry accounting is more complicated than "pick which accounts this transaction touches, and make sure both sides balance."

Accounts: not just bank accounts

An "account" here is a much broader concept than a bank account — it's any bucket a business tracks a balance for:

Cash                 - an asset account: money actually held
Accounts Receivable  - an asset account: money owed TO the business
Revenue              - tracks income earned
Accounts Payable     - a liability account: money owed BY the business
Refunds Payable       - a liability account for pending refund obligations

Every account has a type (asset, liability, equity, revenue, expense), and the type determines whether a debit increases or decreases its balance — this is the part that trips people up coming from plain "debit = money out, credit = money in" everyday intuition, which only holds for one kind of account (like a personal bank statement) and doesn't generalize.

A concrete transaction, worked through

A customer pays $100 for a service, captured immediately:

Debit:  Cash                  $100   (an asset increased — money arrived)
Credit: Revenue                $100   (income earned increased)

Both sides: $100. Balanced. Later, if $30 of that is refunded:

Debit:  Revenue                $30   (reduce the income previously recorded)
Credit: Cash                   $30   (money left)

This second entry is a genuinely new transaction — it doesn't touch or edit the first one. Both entries, and both moments, remain visible in the ledger forever: what was originally earned, and separately, what was later given back. This is exactly the "refund is a new fact, not an edit" principle from the earlier refunds lesson, now expressed in ledger form.

The data model this implies

Account
  id, code, name, type (ASSET | LIABILITY | EQUITY | REVENUE | EXPENSE)

JournalEntry
  id, description, referenceId (e.g. the payment/order this relates to),
  createdAt

JournalLine
  id, journalEntryId, accountId, amount, isDebit (boolean)

A JournalEntry is the header — "this transaction happened" — and its JournalLine rows are the individual debit/credit postings. A service-layer invariant, enforced before any entry is allowed to commit, checks that the sum of debit lines equals the sum of credit lines for that entry, and that an entry has at least two lines (a transaction touching only one account can't represent anything real). This check, plus writing all of an entry's lines inside a single database transaction, is what keeps the ledger internally consistent — an entry either posts completely and in balance, or not at all.

Why engineers building a billing feature run into this even if nobody said "accounting"

"Track how much money we've made, how much is owed to us, and how much we owe back" is, functionally, a request for a ledger, whether or not anyone on the team uses that word. A single payments table with a status column answers "did this specific charge succeed" reasonably well; it answers "what's our actual current revenue, net of refunds, right now" much less reliably, because that answer requires summing across many rows correctly, forever, as new refund/dispute rows keep getting added. A proper ledger makes that summation the natural, structurally-guaranteed result of the account balances themselves, rather than a query someone has to get right every time from scratch.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. A journal entry is created touching only one account, recording a $50 debit with no corresponding credit anywhere. Is this valid?

2. Does a debit always mean money leaving an account, the way it intuitively works for a personal bank statement?

3. A $100 payment is recorded (Debit Cash $100, Credit Revenue $100). Later, $30 is refunded. How is the refund recorded?

4. Why is a ledger a more reliable answer to "what's our current revenue, net of refunds" than a single payments table with a status column?