← All notes
September 30, 2026Full-Stack7 min read

Budget Tracker: An Immutable-Ledger Design

Budget Tracker is an open-source, self-hostable personal and household budget app with English and Arabic (right-to-left) interfaces. It is built on Postgres/Supabase, Cloudflare Workers and React, and every change to money goes through a protected database function. I build and maintain it, and the code is on GitHub under the MIT license.

This note explains the one rule the design rests on and the decisions that follow from it.

Budget Tracker Home screen: net position in USD and LBP, budget versus actual, monthly trend, loans and recent activity

The rule: money is events, balances are derived

The financial core stores money as immutable typed events with signed wallet movements. A wallet balance is never edited; it is derived from the journal. Corrections are new, linked events rather than changes to old rows.

The reason is traceability. When history cannot be rewritten, every balance can be reconstructed, reversals are explicit, and the database can enforce idempotency and reject bad input at the boundary instead of trusting application code to behave.

One posting boundary for every feature

Early on I decided the shared journal is the mandatory control point for every actual money or obligation change, in every feature, including both loan directions. Each feature has its own protected command, and each command posts its linked effects atomically. Planning targets and drafts stay separate from posted money.

The alternative is each feature keeping its own totals. Then wallet balances, loan balances and reports can quietly disagree about the same money. A single enforced boundary removes that class of bug. The list of financial commands is written down in a boundary inventory that tests treat as a contract, so a new feature cannot claim to be fully tracked until it proves coverage and rejection behavior.

Loans as a linked subledger

Every loan action creates a financial event and one immutable loan-principal posting. Lending, borrowing and repayment each add exactly one linked wallet movement in the loan currency, while opening an obligation adds none. Outstanding principal is the sum of loan postings, not a stored total.

Because the event and the effects share one identifier, they are atomic and reversible together. It also lets the database reject an overpayment and serialize concurrent repayments of the same loan. Interest, fees, forgiveness and cross-currency payments are not silently supported; each would need its own posting type and reconciliation tests.

Why a USD to LBP exchange is its own event

The app supports multi-currency wallets in USD and LBP. The two currencies use different minor units, so a currency exchange cannot satisfy the generic transfer rule that both sides are the same currency and sum to zero.

A USD-to-LBP exchange therefore posts only through a dedicated command. It appends a single immutable exchange event with two linked movements, USD out and LBP in. It is never recorded as income, an expense or a plain transfer, so the exchange cannot invent earnings or spending. Other currency pairs, fees or a rate source need a separate command with their own tests.

What the app looks like today

The app has four areas: Home, Journal, Plan and Manage. Home shows the net position per currency, budget versus actual for each category, a monthly trend, open loans and recent activity.

Budget Tracker Journal: the immutable event feed with type filters, search and CSV export

The Journal is the ledger made visible: every event in one feed, filterable by income, expense, transfer, exchange and loans, searchable, with a date range and CSV export. Nothing in it can be edited, which is the point.

Budget Tracker Plan: planned income, left to allocate, category targets and loan commitments

Plan is where targets live. It has tabs for allocation, goals, available cash, upcoming bills and loans, and it keeps planning separate from posted money, so a target never changes a balance.

Budget Tracker Home in Arabic, mirrored right-to-left

The Arabic interface mirrors the whole layout right to left rather than only translating labels. These screens are captured from the running app against the repository’s synthetic test fixtures, so the names and amounts are not real.

I keep source existence, tested behavior and deployed status as three separate claims, because they are three different facts. The repository’s verification notes record which features have which.

Do not retry blindly

The onboarding commands, create space and create wallet, do not take a request ID. That matters when the connection drops after the database already did the work: a naive retry creates a duplicate space or wallet.

So the app sends each of those submissions once. If it cannot tell whether the request arrived, it reads back what the current user can see and matches the name and type, or name and currency, before offering another deliberate submission. If the record is already there, setup simply moves on. Automatic retry only becomes acceptable after the database command gains a reviewed idempotency key, with real Postgres tests behind it.

Security choices that are easy to skip

  • No reusable credentials in app state. The authentication boundary keeps only the current user’s ID and optional email. Supabase owns session storage and refresh, and application state, errors, tests and logs never copy raw session material.
  • Invitations store digests, not secrets. Household invitations keep versioned keyed digests of the recipient and of the one-time token. Accepting, cancelling, replaying and the last-owner check all happen in one PostgreSQL transaction, so a browser write cannot leave membership and audit state half-updated.
  • Ownership is an invariant. Deferred database constraints keep exactly one active owner per household and exactly one active owner membership in every personal space. Revoked and departed members stay as inactive history.

Each of these is enforced in the database with grants, row-level security, constraints and immutable triggers, so an accidental privilege change fails closed instead of open.

Run it yourself

The app needs Node 22.22.0 and pnpm 11.17.0, plus a development Supabase database of your own.

pnpm install
pnpm dev

Copy .env.example to .env.local first and fill in your own values. Verification is pnpm typecheck, pnpm test:worker, pnpm test:ui, pnpm test:db, pnpm build and pnpm test:e2e. The database tests target a dedicated development database, never a production one.

For the cost of unclear specs on a build like this, see Requirements for an AI Automation and Acceptance Criteria for Arabic RTL Features. My menu-bar app is in TopTimer: A Native, Local-First macOS Menu-Bar Timer, and more is on the blog.

FAQ

Is Budget Tracker free and open source?

Yes. It is released under the MIT license and the full source is public on GitHub. It is self-hostable, so you run it against your own Supabase project and Cloudflare setup rather than a hosted service. Issues and pull requests are welcome, and there is no paid tier or subscription.

Which currencies and languages does it support?

Wallets are multi-currency in USD and LBP, and the interface is bilingual in English and Arabic, built to be right-to-left ready. A USD-to-LBP exchange has its own dedicated command. Other currency pairs, fees or a rate source would each need a separate command with its own tests.

Why can’t I edit a wallet balance?

Balances are derived from an immutable journal of events, so there is no balance field to edit. If something was recorded wrongly, you post a linked correction event instead. That keeps the full history reconstructable, makes reversals explicit, and means every change to your money can be audited later.

Can I share a budget with my household?

The app has household spaces scoped by row-level security, and invitations and membership are handled by protected database commands. Sending invitation email is a separate server-side setup with its own approvals, documented in the repository’s invitation runbook, so read that runbook before you rely on it.

Is it a finished product?

It is an actively developed open-source project. The repository tracks whether a feature exists in source, has passing tests and is deployed as three separate facts, and it documents unfinished work as plans. Check the verification notes and roadmap documents for the specific feature you need.

Join the conversation

Your email address will not be published.