Why Your Budgeting App Should Be an Accounting System

Why Your Budgeting App Should Be an Accounting System

Published: August 25, 2026 7 minutes

Every budgeting app I have used eventually stops matching my bank account.

Not dramatically. A transfer gets counted twice. A refund lands in the wrong month. A shared expense gets split and the two halves no longer add up to the whole. Nothing is obviously broken, but the numbers stop being trustworthy, and an untrustworthy budget is worse than no budget, because you keep making decisions from it.

The cause is almost always the same. The app is tracking categories, not keeping books.

Categories versus books

A category-based app models a transaction as one thing: an amount, a date, a label. Fifty dollars, Tuesday, Groceries.

That model has a hole in it. Money never simply is somewhere; it moves from somewhere to somewhere. The fifty dollars did not appear in the Groceries category. It left your chequing account. If the app only records the arrival and not the departure, then the sum of your categories and the balance of your accounts are two independent numbers that happen to be near each other. Nothing forces them to agree, so eventually they don’t.

Double-entry bookkeeping is the four-hundred-year-old answer. Every entry records both sides, and the two sides must be equal. That constraint is the entire trick: if it holds for every entry, your accounts and your categories can never disagree, because they are two views of the same records.

BudgetFox is built on that constraint from the ground up.

Five kinds of account, two directions

The chart of accounts uses the five standard types. In BudgetFox they are income, expense, asset, liability, and equity.

The one piece of jargon worth learning is which direction each type grows in. Assets and expenses increase when you debit them. The other three increase when you credit them. In the codebase that is a single line, and everything else refers back to it:

export type GlType = 'income' | 'expense' | 'asset' | 'liability' | 'equity';

/** Accounts where a debit increases the balance (the rest are credit-normal). */
const DEBIT_NORMAL: ReadonlySet<GlType> = new Set<GlType>(['asset', 'expense']);

Debit and credit are not “money in” and “money out”, and that is the misconception that makes accounting feel arbitrary. They are just the two columns of the ledger. Which column increases a balance depends on the type of account. Once that is one constant in one file, the rest of the system stops arguing about it.

The invariant, enforced in one place

An entry is valid when it balances. That check lives in a single function, and every path that writes to the ledger goes through it:

export function validateEntryLines(lines: EntryLine[]): string | null {
	if (lines.length < 2) return 'A journal entry needs at least two lines.';

	let debit = 0;
	let credit = 0;
	for (const line of lines) {
		if (line.debitCents < 0 || line.creditCents < 0) return 'Amounts cannot be negative.';
		if (line.debitCents > 0 && line.creditCents > 0)
			return 'Each line is either a debit or a credit, not both.';
		if (line.debitCents === 0 && line.creditCents === 0) return 'Every line needs an amount.';
		debit += line.debitCents;
		credit += line.creditCents;
	}
	if (debit !== credit) return 'Debits and credits must balance.';
	return null;
}

This is not sophisticated code. That is the point. The most important rule in the system is fifteen lines, has one home, and is impossible to accidentally bypass. Compare that to a category-based app, where “the totals should add up” is not a rule anywhere. It is a hope distributed across every feature that touches money.

Splits fall out for free

Here is where the model earns its keep. One trip to the supermarket, ninety dollars, but it was really groceries, household supplies, and a bottle of wine. Three categories, one bank movement.

In a category app this is a special case, and special cases are where drift is born. In a ledger it is the ordinary case with more lines. The bank takes the full amount on one side; each category takes its share on the other:

export function splitEntryLines(
	bank: AccountRef,
	splits: SplitAllocation[],
	amountCents: number
): PostingLine[] {
	const moneyIn = amountCents >= 0;
	const magnitude = Math.abs(amountCents);
	const bankLine: PostingLine = {
		glAccountId: bank.id,
		type: bank.type,
		debitCents: moneyIn ? magnitude : 0,
		creditCents: moneyIn ? 0 : magnitude
	};
	const categoryLines = splits.map((s) => ({
		glAccountId: s.account.id,
		type: s.account.type,
		debitCents: moneyIn ? 0 : s.amountCents,
		creditCents: moneyIn ? s.amountCents : 0
	}));
	return [bankLine, ...categoryLines];
}

A single-category transaction is not a different code path; it is this function with one split. Refunds are not a different code path either; a positive amount simply flips which side each line lands on. One function, and the balancing property holds for all of it.

Opening balances, and why credit cards stop being weird

Credit cards break naive budgeting apps. A card balance of two thousand dollars is money you owe, so is that positive or negative? Every app makes a slightly different guess and none of them agree.

In a ledger there is nothing to guess, because a card is a liability and liabilities are credit-normal. Setting an opening balance means posting the amount against equity in whichever direction raises that account’s natural balance:

// The amount to place on the bank's debit side to raise its natural balance.
const onDebitSide = DEBIT_NORMAL.has(bank.type) ? amountCents : -amountCents;

Cash goes up on the debit side, a card’s owed balance goes up on the credit side, and the same function handles both. The awkwardness was never in credit cards. It was in models that had no concept of an account’s natural direction.

Reconciliation becomes possible, not just plausible

The real payoff is bank reconciliation, and it only works because of the two-sided model.

When you move money between two of your own accounts, that movement appears on one statement as a withdrawal and on the other as a deposit. But in the books it is a single entry with two lines. So when you reconcile one account, some of its ledger activity will have no matching statement line at all, the receiving leg of a transfer being the obvious example. That is not an error, and an app that cannot represent it will either double-count the transfer or lose it.

BudgetFox merges both sides into one date-sorted list where every row lands in one of three states:

export type ReconRowState = 'matched' | 'add_to_books' | 'uncleared';

Matched means the statement and the books agree. add_to_books means the bank knows about something you haven’t recorded. Uncleared means you have recorded something the bank hasn’t shown yet. Three states, and the count of rows needing attention is a number you can actually drive to zero, which is the entire promise a budgeting app makes and mostly fails to keep.

The cost, honestly

This is more work than a categories table. There is a chart of accounts to maintain, entries to post rather than rows to insert, and account hierarchies with rollup balances. The domain logic in BudgetFox carries 234 unit tests behind a 100% coverage gate, and it needs them, because a bug in an accounting engine is not a visual glitch. It is a wrong number that someone believes.

There is also a real product risk: nobody wants to see the words “debit” and “credit” in an app for managing household money. The engine has to be rigorous and completely invisible. That turned out to be its own interesting problem, and the subject of the next article: the same ledger renders as “Spending” and “Ledger” for a household, and “Expenses” and “Journal” for a set of business books, from a single vocabulary layer.

The rigour is not the feature. Trustworthy numbers are the feature. The rigour is just the only way I know to get them.

Still running your business on spreadsheets and copy-paste?

I build custom software, integrations and data migrations for teams who have outgrown manual process. Whatever the domain, the approach is the same: model it properly, put the constraints where they cannot be bypassed, and let the software carry the work instead of your team.

See What I Do

Book a Discovery Session