If you've ever shipped an invoicing feature, a wallet, a marketplace payout flow, or even a "simple" subscription billing page, you've written code that touches money. And if you've written code that touches money, you've probably met the bug that doesn't crash anything: the totals are slightly off, nobody notices for three weeks, and then finance asks why the dashboard and the bank statement disagree by $412.37.
Most of those bugs come from one missing idea. Accountants solved it hundreds of years ago, and it fits on a sticky note:
Assets = Liabilities + Equity
That's the accounting equation. To a developer, it's not an accounting concept so much as an invariant: a statement that must be true after every single operation, no matter what. If you think in terms of invariants, assertions, and property-based tests, you already have the right instincts. You just need the vocabulary.
This post covers the equation, how to model it in code, how to test it, and the bugs it catches. Examples are in JavaScript, but nothing here is language-specific.
The equation in plain English
Three buckets:
- Assets: things the business owns or controls. Cash, inventory, equipment, money customers owe you.
- Liabilities: things the business owes to others. Loans, unpaid supplier invoices, wages not yet paid.
- Equity: what's left for the owners after liabilities are subtracted. Owner investment plus accumulated profit, minus what owners take out.
The equation says every asset is paid for by someone's claim on it: either a creditor's (liability) or an owner's (equity). There is no third option. There's no "unexplained" money.
Rearranged, it gives you three solve-for-X formulas:
Assets = Liabilities + Equity
Liabilities = Assets - Equity
Equity = Assets - Liabilities
If you know any two buckets, you can derive the third. That's useful for debugging: if two of your three numbers are trustworthy and the third isn't, the equation tells you what the third should be.
Why developers should care
Here's the framing that clicked for me: the accounting equation is a checksum for your data model.
Every business transaction changes the buckets, but it changes them in a way that keeps both sides equal. Some examples:
| Event | Assets | Liabilities | Equity |
|---|---|---|---|
| Owner invests $50,000 cash | +50,000 | no change | +50,000 |
| Buy $10,000 equipment on credit | +10,000 | +10,000 | no change |
| Pay $4,000 of that debt in cash | -4,000 | -4,000 | no change |
| Earn $5,000 cash revenue | +5,000 | no change | +5,000 |
| Pay $1,200 cash rent | -1,200 | no change | -1,200 |
Look at the pattern. Either both sides move by the same amount, or one side shuffles value between items without changing its total. A transaction that breaks that pattern isn't a "weird edge case." It's a bug.
If you want to play with these without writing code, a free accounting equation calculator has a transaction analyzer mode where you pick a starting balance and a transaction type and it shows the before and after. I'll refer back to it below, but first, let's build the thing properly.
Step 1: stop using floats for money
Before any accounting logic, fix the foundation. Run this in your console:
0.1 + 0.2; // 0.30000000000000004
Floating-point numbers can't represent most decimal fractions exactly. For display that's fine. For an equation whose entire job is exact equality, it's fatal: assets === liabilities + equity will randomly fail on perfectly correct data.
The fix is boring and effective: store money as integers in the smallest unit (cents, paise, pence).
const toCents = (dollars) => Math.round(dollars * 100);
const toDollars = (cents) => (cents / 100).toFixed(2);
toCents(19.99); // 1999
Convert at the edges (parsing input, rendering output) and do all math on integers in between. For very large values or currencies with unusual minor units, reach for BigInt or a decimal library, but integer cents covers most apps.
Step 2: the simplest possible equation checker
Here's the basic check:
function checkEquation({ assets, liabilities, equity }) {
const difference = assets - (liabilities + equity);
return { balanced: difference === 0, difference };
}
checkEquation({ assets: 6_500_000, liabilities: 1_000_000, equity: 5_500_000 });
// { balanced: true, difference: 0 }
Returning the difference (not just a boolean) matters. When something is off, the size of the discrepancy is a clue. A difference exactly equal to one transaction's amount usually means a one-sided write. A difference of a few cents means rounding. A difference that's exactly double a transaction usually means you applied it to the wrong side.
Step 3: model transactions as pure functions
Now let's encode the table from earlier. Each transaction type is a function from state to new state:
function apply(state, tx) {
const { assets, liabilities, equity } = state;
const amt = tx.amount;
switch (tx.type) {
case "owner_investment":
return { assets: assets + amt, liabilities, equity: equity + amt };
case "purchase_on_credit":
return { assets: assets + amt, liabilities: liabilities + amt, equity };
case "pay_liability":
return { assets: assets - amt, liabilities: liabilities - amt, equity };
case "cash_revenue":
return { assets: assets + amt, liabilities, equity: equity + amt };
case "cash_expense":
return { assets: assets - amt, liabilities, equity: equity - amt };
default:
throw new Error(`Unknown transaction type: ${tx.type}`);
}
}
Replaying the earlier scenario (amounts in cents):
let state = { assets: 0, liabilities: 0, equity: 0 };
state = apply(state, { type: "owner_investment", amount: 5_000_000 });
state = apply(state, { type: "purchase_on_credit", amount: 1_000_000 });
state = apply(state, { type: "cash_revenue", amount: 500_000 });
console.log(state);
// { assets: 6500000, liabilities: 1000000, equity: 5500000 }
console.log(checkEquation(state).balanced); // true
That's $65,000 in assets, $10,000 owed, $55,000 owner's stake. If you'd rather sanity check a scenario like this without opening an editor, you can enter the same numbers into the Assets = Liabilities + Equity calculator and compare its output against your code. Cross-checking your implementation against an independent tool is a cheap way to catch a sign error early.
Step 4: test the invariant, not just examples
Example-based tests check the cases you thought of. The equation is a perfect candidate for property-based testing, where a library generates hundreds of random inputs and verifies a property always holds.
With fast-check:
import fc from "fast-check";
import { test, expect } from "vitest";
const txArb = fc.record({
type: fc.constantFrom(
"owner_investment",
"purchase_on_credit",
"pay_liability",
"cash_revenue",
"cash_expense"
),
amount: fc.integer({ min: 1, max: 1_000_000 }),
});
test("accounting equation holds after any sequence of transactions", () => {
fc.assert(
fc.property(fc.array(txArb), (txs) => {
const end = txs.reduce(apply, { assets: 0, liabilities: 0, equity: 0 });
return end.assets === end.liabilities + end.equity;
})
);
});
If someone later "optimizes" one branch of that switch and forgets a side, this test fails with a minimal counterexample. That's a regression net you get almost for free.
One honest caveat: this test proves the equation holds, not that your business rules hold. As written, pay_liability can push liabilities negative (paying a debt you don't have). That's a separate validation concern, and the equation won't catch it. Invariants and validation rules are different layers; you want both.
Step 5: the extended equation (where profit lives)
The basic equation lumps everything into "equity." Real reports break it down, because owners want to know why equity changed. The extended version is:
Assets = Liabilities + (Beginning Equity + Revenue - Expenses - Withdrawals)
Which means net income and ending equity fall out naturally:
function extendedEquation({ beginningEquity, revenue, expenses, withdrawals }) {
const netIncome = revenue - expenses;
const endingEquity = beginningEquity + netIncome - withdrawals;
return { netIncome, endingEquity };
}
extendedEquation({
beginningEquity: 5_000_000,
revenue: 800_000,
expenses: 300_000,
withdrawals: 100_000,
});
// { netIncome: 500000, endingEquity: 5400000 }
This is the bridge between "the equation" and the reports your users actually look at. Revenue and expenses are really just temporary slices of equity that get rolled into it at the end of a period.
Step 6: a tiny double-entry ledger
Here's where it gets fun. Real accounting systems don't hand-code transaction types like we did in Step 3. They use double-entry bookkeeping: every transaction is a set of debit and credit lines that must sum to the same total. If they don't, the system rejects the entry.
That single rule is what guarantees the accounting equation holds. Let's build a minimal version:
const ACCOUNT_TYPE = {
cash: "asset",
equipment: "asset",
accountsPayable: "liability",
ownerCapital: "equity",
serviceRevenue: "revenue",
rentExpense: "expense",
ownerWithdrawals: "withdrawal",
};
const DEBIT_NORMAL = new Set(["asset", "expense", "withdrawal"]);
class Ledger {
constructor() {
this.balances = {}; // signed: debits positive, credits negative
}
post(lines) {
const debits = lines.reduce((s, l) => s + (l.debit ?? 0), 0);
const credits = lines.reduce((s, l) => s + (l.credit ?? 0), 0);
if (debits !== credits) {
throw new Error(`Unbalanced entry: debits ${debits} != credits ${credits}`);
}
for (const { account, debit = 0, credit = 0 } of lines) {
if (!ACCOUNT_TYPE[account]) throw new Error(`Unknown account: ${account}`);
this.balances[account] = (this.balances[account] ?? 0) + debit - credit;
}
}
total(type) {
const raw = Object.entries(this.balances)
.filter(([account]) => ACCOUNT_TYPE[account] === type)
.reduce((sum, [, bal]) => sum + bal, 0);
return DEBIT_NORMAL.has(type) ? raw : -raw;
}
equation() {
const assets = this.total("asset");
const liabilities = this.total("liability");
const equity =
this.total("equity") +
this.total("revenue") -
this.total("expense") -
this.total("withdrawal");
return { assets, liabilities, equity, balanced: assets === liabilities + equity };
}
}
And using it:
const ledger = new Ledger();
// Owner invests $50,000
ledger.post([
{ account: "cash", debit: 5_000_000 },
{ account: "ownerCapital", credit: 5_000_000 },
]);
// Buy $10,000 equipment on credit
ledger.post([
{ account: "equipment", debit: 1_000_000 },
{ account: "accountsPayable", credit: 1_000_000 },
]);
// Earn $5,000 in fees
ledger.post([
{ account: "cash", debit: 500_000 },
{ account: "serviceRevenue", credit: 500_000 },
]);
console.log(ledger.equation());
// { assets: 6500000, liabilities: 1000000, equity: 5500000, balanced: true }
Notice what happened: we never checked the equation while posting. We only enforced "debits equal credits" per entry, and the equation held as a mathematical consequence. That's the elegance of the design. The invariant is protected at the only door data can come through.
Try breaking it:
ledger.post([{ account: "cash", debit: 100 }]);
// Error: Unbalanced entry: debits 100 != credits 0
A whole class of "we recorded half a transaction" bugs becomes impossible to write.
Bugs this mental model catches
After you internalize the equation, you start spotting these in code review:
- One-sided writes. A refund handler decrements the customer's balance but never touches cash. Assets and claims no longer match.
-
Derived totals stored as columns. If
total_assetsis a column that's updated separately from the rows that make it up, it will drift. Compute it, or update it in the same database transaction. - Rounding in the wrong place. Splitting $100.00 three ways and rounding each share gives $99.99 total. Decide where the leftover cent goes, and make it deterministic.
- Timing mismatches. Recording revenue when the invoice is sent but the cash when it's received is fine in accrual accounting, but only if you also record the receivable in between. Skipping that step leaves one side short.
- Missing audit trail. The equation tells you that you're off, not where. Append-only entries with timestamps and IDs let you bisect a discrepancy like you'd bisect a bad commit.
- "Fix it" updates. Editing historical entries in place destroys your ability to reconcile. Post a correcting entry instead.
A quick cheat sheet
- The equation is
A = L + E. Treat it as an invariant, like a database constraint. - Money is integers. Convert at the edges.
- Return the difference, not just true/false.
- Model changes as balanced entries, not as ad hoc field updates.
- Property-test the invariant; unit-test the business rules separately.
- Never edit history; append corrections.
Where to go from here
If you want to go deeper, the natural next steps are the statements built on top of this equation: the balance sheet (a snapshot of A, L, and E), the income statement (the revenue and expense slices of equity), and the cash flow statement (why cash differs from profit). If you're checking your own implementation, an independent tool like this free A=L+E calculator is handy for verifying the basic, extended, and solve-for-missing-value cases against your code's output.
You don't need an accounting degree to build money features responsibly. You need a handful of ideas, and this equation is the most valuable one: every asset has a claim against it, and the books must always say so.
Over to you
How do you handle money in your stack? Integer cents, a decimal library, or a full ledger service? And has a "tiny rounding bug" ever turned into a very long week for you? Share it in the comments. I'd love to compare war stories.
If this was useful, a ❤️ or 🦄 helps other devs find it, and follow for more practical breakdowns of "boring" fundamentals that save real production pain.
Top comments (0)