danielwkiwi

Family budget with Hledger and custom harness

Plain-Text Budgeting for Families: How I Built a Full Finance System with hledger, Envelope Budgeting, and Google Sheets

Or: Why I gave up on budgeting apps and built my own system using nothing but text files, a command-line tool, and a free cloud spreadsheet.


The Problem

My wife and I needed a budget. Not a vague "we spend about this much" kind of budget, but a real, trackable system where we could see exactly where every dollar went, plan ahead for upcoming expenses, and share the state of our finances easily. We tried apps. We tried spreadsheets. Nothing stuck because every tool either:

  • Locked us in to a proprietary format we couldn't export or control
  • Required a subscription for features we actually needed
  • Couldn't handle the nuance of real-world family finances (transfers between accounts, one-off expenses, irregular income)
  • Made it hard to share the full picture with my partner in a way she could actually read

So I built something different. It runs on text files, a free open-source tool called hledger, a custom desktop app, and a shared Google Sheet. It costs nothing, it will never disappear because a startup folds, and my wife can check our budget from her phone whenever she wants.

This post explains every concept and every piece of the system so that someone with no accounting background can understand not just what we built, but why.


Part 1: Double-Entry Accounting (The Foundation)

What Is It?

Double-entry accounting is a system where every financial transaction is recorded in at least two places. This sounds complicated, but it's actually a simple idea with a powerful guarantee: money never appears or disappears. It always moves from somewhere to somewhere.

Every transaction has two or more "postings" — lines that say which account gained money and which account lost money. The total must always balance to zero.

A Simple Example

Say you spend $50 at the grocery store using your debit card. In double-entry terms:

2026/04/15 Woolworths
    Expenses:Food:Groceries       $50.00
    Assets:ANZ:Checking          -$50.00

This says: "Groceries went up by $50, and my checking account went down by $50." The total is $0. Balanced. Money moved from one place to another.

If you got paid $5,000:

2026/04/01 Salary
    Assets:ANZ:Checking        $5,000.00
    Income:Daniel:DayJob     -$5,000.00

Your checking account went up, your "income" record went down (negative income means you earned it). Balanced again.

Why Does This Matter?

Single-entry accounting (what most people do with a spreadsheet) just tracks "I spent $50 on groceries." But where did that $50 come from? Double-entry forces you to answer that question every single time. This means:

  • You can't forget an account. If money left your checking account, it must have gone somewhere.
  • Errors are obvious. If things don't balance, you know immediately that something is wrong.
  • You get a complete picture. Not just "what did I spend?" but "where did all my money go, and where is it now?"

Why Not Just Use a Spreadsheet?

You can! And many people do. But spreadsheets break down when you want to track hundreds of transactions across multiple accounts, categories, and time periods. They also don't enforce the balancing rule — you can type whatever you want. A dedicated tool that requires balanced entries catches mistakes automatically.


Part 2: Plain-Text Accounting

The Idea

Plain-text accounting means storing your financial records in human-readable text files instead of a database or a proprietary app format. A popular tool for this is hledger, an open-source command-line program that reads these text files and produces every report you could want.

What Does It Look Like?

Here's a real transaction from our system:

2026/04/02 * Company
    Expenses:Food:Groceries          52.30 NZD
    Assets:ANZ:Checking             -52.30 NZD  ; ofxid: 1.055555-00.20260402.1
  • 2026/04/02 — the date
  • * — means this transaction is cleared/confirmed
  • Company — the payee (who we paid)
  • The two lines below are the postings: groceries expense up, checking account down
  • The ; ofxid: comment is a unique ID from our bank — we'll get to why this matters later

Why Text Files?

  • You own your data forever. No app can go bankrupt and take your financial history with it. Text files will be readable in 50 years.
  • Version control. Your financial records can live in git. Every change is tracked. You can see exactly what changed, when, and why.
  • No vendor lock-in. You can switch tools, write custom scripts, or build your own interface on top of the same data.
  • Transparency. You can open a journal file in any text editor and understand exactly what's there. No hidden database tables.
  • Scriptability. Because it's text, you can programmatically generate, transform, and query your data with any programming language.

The hledger Tool

hledger reads these journal files and gives you powerful reporting:

# How much did we spend on groceries this month?
hledger bal Expenses:Food:Groceries -b 2026/04 -e 2026/05

# What's our net worth over time?
hledger bal Assets Liabilities --monthly --cumulative

# Show all transactions in April
hledger reg -b 2026/04 -e 2026/05

It handles multi-currency, account hierarchies, time-based queries, budgets, and much more. It's a full accounting engine that reads a simple text format.


Part 3: Envelope Budgeting

The Concept

Envelope budgeting is a method where you divide your income into virtual "envelopes," each representing a spending category. When you get paid, you put money into each envelope. When you spend, you take money out of the relevant envelope. If an envelope is empty, you either stop spending in that category or move money from another envelope.

The name comes from the literal practice of putting cash into physical envelopes labeled "Groceries," "Rent," "Entertainment," etc.

Why Envelope Budgeting Works

  • Intentional spending. You decide before the month starts where your money will go.
  • Category independence. Overspending on dining out doesn't affect your grocery budget — they're separate envelopes.
  • Visibility. You always know how much is left in each category.
  • Rolling balances. Money left in an envelope at month-end stays there, building up for larger future expenses.

How We Implement Envelopes in hledger

hledger doesn't have built-in envelope budgeting, but we implement it using virtual postings. A virtual posting is a posting in square brackets that doesn't affect the real account balance — it's metadata for tracking purposes.

When we import a $50 grocery transaction, our system automatically generates a mirror budget posting:

2026/04/02 * Woolworths
    Expenses:Groceries                50.00 NZD
    Assets:ANZ:Checking              -50.00 NZD
    [Budget:Expenses]                -50.00 NZD
    [Budget:Groceries]                50.00 NZD

The first two lines are the real transaction (money actually moved). The bracketed lines are the budget envelope tracking: - [Budget:Expenses] decreases — we spent from our overall expense budget - [Budget:Groceries] increases — this specific envelope "absorbed" the spending

Wait, why does Budget:Groceries increase when we spent money? Because we think of it in reverse: the balance of Budget:Groceries represents how much we've allocated minus how much we've spent. When we allocate $1,200 to groceries at the start of the month, Budget:Groceries is +$1,200. Each grocery purchase reduces it. If it's positive, we have money left. If it's negative, we've overspent.

Monthly Allocation

At the start of each month (or whenever income arrives), we allocate money into our budget envelopes through a special allocation transaction:

2026/04/01 Budget Allocation
    [Budget:Groceries]       1,200.00 NZD
    [Budget:Insurance]         850.00 NZD
    [Budget:Petrol]            380.00 NZD
    [Budget:Power]             400.00 NZD
    [Budget:Available]      -2,830.00 NZD

Budget:Available is the source — it holds unallocated income. When it goes negative, it means we've allocated more than we've earned. The goal is for Budget:Available to be exactly $0 after all envelopes are funded.

We have 30 budget categories covering everything from groceries ($1,200/month) to parking ($15/month), totaling $6,500/month balanced against our income.


Part 4: Virtual Accounts and the Budget Mirror

What Are Virtual Postings?

In hledger, a posting in square brackets [Like This] is "virtual." It doesn't participate in the transaction's balance check. Real postings must balance to zero; virtual postings are tracked separately.

This is the mechanism that makes our envelope system work. Real money flows through real accounts (Checking, Groceries expense). Budget tracking flows through virtual [Budget:*] accounts in parallel.

The Envelope Mirror

Every time a transaction is categorized to an Expenses: or Income: account, our system automatically generates matching Budget: virtual postings. This happens:

  1. At import time — the F# import tool generates budget postings automatically
  2. At edit time — when you recategorize a transaction in the UI, the budget postings are regenerated to match

This means the budget envelopes are always in sync with actual spending. There's no manual step to "update the budget" after categorizing a transaction.

Why Not Just Use hledger's Built-in Budget?

hledger has a --budget flag that works with periodic transactions, but it's rigid — it expects every category to have a fixed monthly allocation and doesn't easily support irregular income or mid-month adjustments. Our approach gives us full control: we can allocate any amount at any time, carry balances forward, and see exact per-envelope balances with a simple hledger bal Budget: query.


Part 5: Importing Transactions from the Bank

The Problem with Manual Entry

Double-entry accounting is powerful, but entering every transaction by hand is tedious and error-prone. In a family with two bank accounts, dozens of transactions per week, and multiple spending categories, manual entry doesn't scale.

Our Solution: Automated OFX Import

Our bank (ANZ, in New Zealand) lets us download transaction files in OFX format (Open Financial Exchange — an XML-based standard for bank data). We've built an automated pipeline that:

  1. Drops OFX files into a folder — I download them from the bank's website and put them in ingest/
  2. Matches them to the right account — the filename contains the account number (e.g., 05-0555-055555-00_Transactions_...ofx), and our config maps that to Assets:ANZ:Checking
  3. Converts OFX to hledger format — using a tool called ledger-autosync that parses the XML and outputs properly formatted journal entries
  4. Auto-categorizes transactions — using a rules file that maps payee names to expense categories
  5. Deduplicates — each OFX transaction has a unique ID (ofxid). Our system checks against existing journal entries and skips anything already imported
  6. Appends to the journal — new transactions are added to base.journal
  7. Moves the OFX file — successfully processed files go to processed/, failures go to error/

Auto-Categorization Rules

The rules file (payee_rules.json) contains hundreds of patterns. Here are a few:

[
  { "pattern": "Woolworths",  "account": "Expenses:Groceries" },
  { "pattern": "2Degrees",    "account": "Expenses:Internet" },
  { "pattern": "Company",     "account": "Expenses:Food:Dining" },
  { "pattern": "BP",          "account": "Expenses:Petrol" }
]

Rules are evaluated first-match-wins with case-insensitive substring matching. If no rule matches, the transaction defaults to Expenses:Misc — a catch-all that shows up in the UI for manual categorization.

Deduplication

This is critical. If you download the same OFX file twice (because it overlaps with a previous download), you'd get duplicate transactions. Our system prevents this two ways:

  1. ledger-autosync's built-in deduplication — it checks against the existing journal using the ofxid
  2. Our own ofxid check — we extract all ofxids from the existing journal into a HashSet and skip any incoming transactions that match

The ofxid is stored as a comment in the journal:

Assets:ANZ:Checking  -52.30 NZD  ; ofxid: 1.0550555-00.20260402.1

The Import Tool

The import pipeline is an F# command-line tool (hledger-import/) with no external NuGet dependencies — it uses only .NET's built-in JSON library. It's lean, fast, and reproducible. On Linux, all dependencies (dotnet SDK, hledger, ledger-autosync) are provided by a Nix flake, so nix develop gives you the exact same environment every time.


Part 6: Handling Transfers Between Accounts

The Problem

We have two bank accounts: a checking account for daily spending and a "mortgage float" account where we park money before it goes to the mortgage. When money moves between them, it appears as two separate transactions — one debit from the source, one credit to the destination — often on different days.

The Solution: A Clearing Account

Instead of trying to match the two sides of a transfer together (which is fragile — dates differ, descriptions differ), we use a clearing account called Assets:Pending:Transfers:

; Money leaves checking (April 2)
2026/04/02 Transfer Debit Transfer 170504
    Assets:ANZ:Checking              -2,000.00 NZD
    Assets:Pending:Transfers          2,000.00 NZD

; Money arrives in mortgage float (April 3)
2026/04/03 Transfer Credit Transfer 170504
    Assets:Pending:Transfers         -2,000.00 NZD
    Assets:ANZ:MorgageFloat           2,000.00 NZD

The clearing account is like a waiting room. Money enters, money leaves, and when all transfers are complete, its balance is exactly $0. If it's not $0, there's an in-flight transfer — money that left one account but hasn't arrived at the other yet.

Why This Is Elegant

  • No matching logic needed. Each side is an independent transaction. No algorithm to pair them up.
  • Dates can differ. The debit might post on Tuesday, the credit on Wednesday. Both are accurate.
  • Visual indicator. A non-zero Pending:Transfers balance immediately tells you something is in flight.
  • Auto-categorized. Our rules file matches transfer descriptions and routes them to the clearing account automatically.

Part 7: The Budget UI (budget-ui)

Why a Custom App?

hledger has hledger-ui (a terminal UI) and hledger-web (a browser UI). Both are great for querying and reporting, but neither is designed for editing transactions — specifically, for categorizing transactions and managing budget allocations.

I needed an app where I could:

  • See all transactions in a scrollable list
  • Quickly recategorize any transaction with autocomplete
  • See and manage budget envelope allocations
  • Check that my hledger balances match my actual bank balances
  • Publish reports for my wife to see

What I Built

budget-ui is a cross-platform desktop application built with F# and Avalonia.FuncUI (a framework for building GUIs using the Model-View-Update / Elmish pattern). It runs on both Windows and Linux.

Key Features

Transaction Categorization

  • Transactions load from hledger print --output-format=json into a searchable list
  • Selecting a transaction shows its postings in a detail panel
  • An autocomplete dropdown lets you recategorize to any Expenses:* or Income:* account
  • Budget virtual postings are automatically regenerated when you change a category
  • Changes are batched in memory and saved to the journal file on Ctrl+S

Bank Balance Verification

  • Enter your bank-stated balance from balances.txt
  • The app compares it against hledger's calculated balance per account
  • Identifies transactions that haven't been confirmed yet
  • Highlights mismatches so you can find discrepancies

Budget Allocation

  • A full-screen view showing all 30 budget categories with current balances
  • Enter allocation amounts for each category
  • Validates that total allocations don't exceed available funds
  • Writes a virtual-posting transaction to allocations.journal
  • Auto-reloads after allocation to show updated balances

Built-in hledger Terminal

  • A command runner panel where you can type any hledger command
  • See the output immediately — great for ad-hoc queries like "how much did we spend on petrol last month?"

Keyboard Shortcuts

  • C — mark transaction as checked (reviewed)
  • Ctrl+S — save all changes
  • Ctrl+R — reload data from hledger
  • Enter — focus the category autocomplete
  • And more — all designed for fast, keyboard-driven workflow

Why Avalonia.FuncUI?

Avalonia is a cross-platform .NET UI framework (think "WPF that works on Linux and Mac"). FuncUI adds a functional reactive layer where the UI is a pure function of the model state. No mutable UI state, no event handlers tangled with business logic. The entire app state is an immutable F# record, and the UI is rebuilt declaratively from that state every time it changes.

This makes the app reliable, testable (we test the pure update function with xUnit), and surprisingly fast — the window renders in ~0.27 seconds.


Part 8: Reports Without Headless Browsers or LaTeX

Why This Matters

Many finance tools generate PDF reports using heavy dependencies:

  • Headless browsers (Puppeteer, Playwright) — requires installing Chrome/Chromium, hundreds of megabytes
  • LaTeX — a full typesetting system, huge install, complex templates
  • External APIs — sends your financial data to a third party

None of these are acceptable for a personal finance tool. They're heavy, they're fragile across platforms, and some of them leak private data.

Our Approach: MigraDoc for PDF, Google Sheets for Sharing

PDF Export with MigraDoc

We use MigraDoc (via the PDFsharpCore NuGet package) to generate PDFs. This is a pure managed .NET library — no external dependencies, no system installs, no headless browsers. It runs on any platform where .NET runs.

Even the fonts are embedded. Noto Sans, Noto Serif, and Noto Sans Mono TTF files are packaged inside the assembly as embedded resources. This means identical PDF rendering on every computer, regardless of what fonts are installed. No "it looks different on my machine" problems.

We generate two PDFs:

  • Summary PDF — Budget balances, net worth over time, weekly and monthly spending trends
  • Transactions PDF — A full transaction table with date, description, account, category, amount

Both feature: - Bold headers with light gray background - Currency formatting on amount columns - Portrait for narrow tables (budget balances), landscape for wide ones (transactions, multi-period reports) - Page breaks between sections

Why Not Just Use Google Sheets for Everything?

Google Sheets is great for sharing, but it's not great for generating archival documents. A PDF is a fixed snapshot — "here's exactly what our finances looked like on April 25, 2026." A shared spreadsheet is always changing. Both serve different purposes, so we generate both.


Part 9: Sharing with My Wife via Google Sheets

The Real-World Problem

Budgeting systems that only one person can see don't work in a family. My wife doesn't want to learn hledger, or open a desktop app, or read text files. She wants to open something on her phone and see: "How much is left in the grocery budget?" "What did we spend this month?" "Are we on track?"

The Solution: Google Sheets Publishing

From budget-ui, I click "Publish Sheets" and the app:

  1. Authenticates with Google using a service account (no OAuth login flow — just a JSON key file)
  2. Fetches report data from hledger — budget balances, net worth, transactions, weekly and monthly spending
  3. Writes to a shared Google Sheet — five tabs, each fully replaced with fresh data
  4. Formats the sheet — bold headers, frozen header row, currency formatting, auto-sized columns

The Five Sheet Tabs

Tab What It Shows
Budget Balances Current balance in each budget envelope — answers "how much can I still spend on groceries?"
Net Worth Monthly running totals of all assets minus liabilities — answers "are we building wealth?"
Transactions Every transaction with date, description, account, category, and amount — answers "what did we spend?"
Weekly Spending Per-category spending broken down by week — answers "are we spending more this week than last?"
Monthly Spending Per-category spending broken down by month — answers "how does this month compare to last month?"

Why Google Sheets?

  • Free. No subscription, no limits on the number of spreadsheets.
  • Familiar. Everyone knows how to read a spreadsheet.
  • Mobile-friendly. The Google Sheets app works great on phones.
  • Shareable. One click to share with my wife; she sees updates whenever I publish.
  • Interactive. She can add her own formulas, charts, or notes without affecting the source data.
  • No headless browser. We use the Google Sheets API v4 directly — a lightweight HTTP client, not a browser automation tool.

The Technical Setup

  1. Create a Google Cloud project and enable the Sheets API
  2. Create a service account and download the JSON key file
  3. Create a Google Sheet and share it with the service account's email address
  4. Configure the key path and spreadsheet ID in ui-settings.json

That's it. No OAuth flow, no browser popup, no user login. The app authenticates directly with the key file and writes to the sheet.

Formatting Details

After all data is written, a single batch update call applies: - Bold headers with light gray background (#E8E8ED) - Frozen header row — stays pinned when scrolling on mobile - Currency format on amount columns — stored as real numbers (not strings), so Sheets can do math on them - Auto-resized columns — fits to content width for readability


Part 10: Net Worth Tracking

Beyond Budgeting

Budgeting is about short-term spending. Net worth is about long-term wealth. Our system tracks both.

What We Track

Asset/Liability Type
ANZ Checking Account Cash
ANZ Mortgage Float Cash
Sharesies (investment platform) Investment
SmartShares (index funds) Investment
KiwiSaver (retirement fund) Retirement
Family Home (property) Property
Home Loan Mortgage/Liability

How It Works

These are tracked in a separate journal file (networth.journal) with periodic balance assertions. I update them when values change — not transactionally, but every so often (when I check investment balances, when rates notices arrive, etc.).

The net worth report is simply:

hledger bal Assets Liabilities --monthly --cumulative

This produces a monthly running total: the sum of everything we own minus everything we owe, over time. It's published to the "Net Worth" tab in Google Sheets and included in the summary PDF. Watching this number trend upward over months and years is one of the most motivating aspects of personal finance.


Part 11: The Full Daily Workflow

Here's what a typical day looks like with this system:

1. Download Transactions

Log into the bank website, download the latest OFX files, drop them in the ingest/ folder.

2. Run the Import

dotnet run

The F# import tool processes each OFX file, auto-categorizes what it can, deduplicates, and appends to the journal. Successfully processed files move to processed/.

3. Review in budget-ui

Open the desktop app. New transactions appear in the list. Any that couldn't be auto-categorized show up as Expenses:Misc. I: - Select each uncategorized transaction - Assign the correct category from the autocomplete dropdown - Optionally mark transactions as "checked" (reviewed) - Press Ctrl+S to save

4. Allocate the Budget

If it's the start of the month, I open View → Budget Allocation and distribute income into the 30 envelope categories. The system validates that total allocations don't exceed available funds.

5. Publish for My Wife

Click Actions → Publish Sheets. The app writes fresh data to our shared Google Sheet. My wife can check it on her phone at any time.

6. (Optional) Export PDFs

Click Actions → Export Summary PDF or Export Transactions PDF for an archival snapshot.

Total time: about 10-15 minutes per week.


Part 12: The Technology Stack

Component Technology Why
Accounting engine hledger Open-source, plain-text, powerful reporting, actively maintained
OFX conversion ledger-autosync Battle-tested OFX parser, handles deduplication
Import pipeline F# on .NET 8 Strongly typed, no NuGet dependencies, robust parsing
Desktop UI F# + Avalonia.FuncUI Cross-platform, MVU/Elmish pattern, fast rendering
Cloud sharing Google Sheets API v4 Free, familiar, mobile-friendly, lightweight HTTP
PDF export MigraDoc (PDFsharpCore) Pure managed .NET, no system dependencies, embedded fonts
Reproducible env Nix flake (Linux) Pin exact versions of dotnet, hledger, ledger-autosync
Data format Plain text (hledger journal) Future-proof, version-controllable, human-readable

Why F#?

F# is a functional-first language on .NET. It gives us: - Discriminated unions — perfect for modeling message types in the MVU pattern - Immutable records — the entire app state is immutable by default - Pattern matching — elegant handling of different message types - Result typesResult<'T, string> for error handling without exceptions - Interop with .NET — access to Google's API client libraries, Avalonia UI framework, etc.

Zero External NuGet Dependencies in the Importer

The import tool (hledger-import/) uses only System.Text.Json from the .NET base framework. No third-party packages. This was a deliberate choice — the import pipeline should be as simple and reliable as possible. The JSON parsing is straightforward enough that we don't need a heavy serialization library.


Part 13: What This System Gives Us

For Me (the household CFO)

  • Complete control over our financial data
  • Automation that handles 90% of transaction categorization
  • Powerful reporting via hledger's query language
  • A desktop app optimized for fast keyboard-driven categorization

For My Wife

  • A Google Sheet she can check anytime from her phone
  • Simple answers to simple questions: "Can I buy this?" → check the grocery envelope balance
  • No software to learn — just a spreadsheet

For Our Family

  • A permanent record that will never be lost to a startup shutting down
  • Shared visibility into our financial health
  • Intentional spending through envelope budgeting
  • Long-term tracking of net worth growth

Part 14: Could You Build This Too?

Absolutely. Every piece of this system is open-source or free:

  • hledger — free, open-source, available on all platforms
  • ledger-autosync — free, open-source Python tool
  • F# / .NET 8 — free, open-source
  • Avalonia.FuncUI — free, open-source
  • Google Sheets API — free tier is more than sufficient
  • PDFsharpCore / MigraDoc — free, open-source
  • Nix — free, open-source, for reproducible builds on Linux

The total cost of running this system is $0/month (beyond what you already pay for bank accounts and internet).

What You'd Need to Adapt

  • Your bank's export format — if your bank doesn't support OFX, it probably supports CSV, which can be converted
  • Your category structure — our 30 categories are specific to our family; yours will be different
  • Your account structure — we have ANZ accounts in New Zealand; yours will vary
  • Your currency — hledger handles any currency; just change the commodity declaration

Conclusion

This system isn't fancy. It's text files, a command-line tool, a desktop app, and a spreadsheet. But it's ours. It costs nothing, it will never disappear, and it gives us a level of financial clarity that no app ever did.

The combination of plain-text accounting (hledger), envelope budgeting (virtual postings), automated import (OFX pipeline), a custom UI for editing, PDF export without heavy dependencies (MigraDoc), and cloud sharing (Google Sheets) covers every need a family has around money:

  • Tracking — where did the money go?
  • Budgeting — where should it go?
  • Planning — are we building wealth over time?
  • Sharing — can both partners see the full picture?
  • Archiving — will we have this data in 10 years?

If any of this resonates with you, start with hledger. Download it, create a journal file with your opening balances, and start tracking. You don't need the import pipeline or the UI or the Sheets integration on day one. Start with:

2026/01/01 Opening Balances
    Assets:Checking              1,000.00 USD
    Equity:OpeningBalances      -1,000.00 USD

And build from there.


This system was built with hledger, F#, Avalonia.FuncUI, Google Sheets API v4, MigraDoc, and a lot of love for plain text.