For three versions, OurBudgetTracker has only known about the past. Every expense in it is something that already happened — a charge, a receipt, a number you typed in after the money left your account. That’s a faithful model of a ledger, but it’s a thin model of how anyone actually thinks about a trip. Before you go, you don’t have receipts. You have a shape: hotel will be about $1,800, flights about $800, and a fuzzy cloud of smaller stuff you’ll sort out later.
v0.3 is about teaching the app to hold that shape. It shipped today, and the headline feature is estimated expenses — known-but-not-yet-incurred costs you enter ahead of time, see folded into your budget as a forecast, and then convert into real expenses once the money actually moves. The whole thing started as a design spec yesterday and a 2,600-ish-line plan this morning, and by the end of the day it was tagged 0.3.0 and deployed to server01.
But the part I want to write about isn’t the feature. It’s what the feature found.
The two-row model
The first real decision was structural, and it’s the kind of decision that looks like bikeshedding until six months later when it isn’t. Do you add a status flag to the expenses table — planned vs actual — or do you give planned items their own table?
I went with a separate planned_expenses table. Migration 005, additive, no destructive changes to the existing schema. The reason is variance. The whole point of an estimate is that you want to compare it to reality afterward — “planned $1,800, actual $1,750, came in $50 under.” A status flag can’t hold both numbers; flipping planned to actual overwrites the estimate with the truth and the comparison is gone. With a separate table, the estimate is a durable row that back-links to the actual expense it became:
UPDATE planned_expenses
SET converted_at = datetime('now'), converted_expense_id = ?, updated_at = datetime('now')
WHERE id = ?
The estimate doesn’t die when it’s spent. It closes out, points at its actual, and keeps its original number forever.
Conversion is the one operation in the feature that touches two tables, so it’s the one that has to be atomic. The convertPlanned function wraps both writes — insert the real expense, stamp the planned row as converted — in a single better-sqlite3 transaction:
export function convertPlanned(db, id, input): ExpenseRow {
const tx = db.transaction((): ExpenseRow => {
const p = getPlanned(db, id);
if (!p) throw new Error(`planned_expense ${id} not found`);
if (p.deleted_at) throw new Error('cannot convert a deleted estimate');
if (p.converted_at) throw new Error('estimate already converted');
const expense = createExpense(db, {
trip_id: p.trip_id,
category_id: input.category_id ?? p.category_id,
amount_cents: input.amount_cents,
payer_user_id: input.payer_user_id ?? p.payer_user_id,
estimated_amount_cents: p.estimated_amount_cents, // <- variance carried forward
...
});
db.prepare(`UPDATE planned_expenses SET converted_at = datetime('now'),
converted_expense_id = ? WHERE id = ?`).run(expense.id, id);
return expense;
});
return tx();
}
The estimated_amount_cents rides along onto the actual expense, which is what lets the converted row render “planned vs actual” without a join back to the original. The double-convert guard (if (p.converted_at)) matters more than it looks: “Mark spent” is a button on a home-screen card, and home-screen cards get double-tapped. Without that check, an impatient thumb would write two real expenses from one estimate.
The forecast math on the home screen falls out of all this. The headline number stopped being “spent” and became left to allocate = budget − spent − outstanding planned, drawn as a single stacked bar — spent solid, planned striped over it. Estimates that have been converted drop out of “outstanding planned” automatically because the query filters converted_at IS NULL, so the bar reshapes itself as you mark things spent without any special-casing.
The turn: the new guard the old path never had
Here’s the part that wasn’t in the plan.
I built the planned→actual routes carefully because they’re new and I was paying attention to them. The convert route and the planned-edit route both got a guard called assertCategoryInTrip — after checking that you’re a member of the category’s trip, they also check that the category actually belongs to the same trip as the item you’re editing. That second check exists because the app went multi-trip in v0.2, and in a multi-trip world “you can see this category” and “this category belongs here” are different questions.
Then I ran the whole-branch review across the merged work, and it pointed at the old path — the v0.2 actual-expense edit route — and asked: where’s that guard?
It wasn’t there. The expense update action validated the submitted category with assertCategoryAccess, which only checks that you’re a member of the category’s trip. It never checked that the category belonged to the same trip as the expense being edited. A member of two trips could submit a category from trip A onto an expense in trip B — via a stale URL or a hand-edited form — and the app would happily write the cross-trip misattribution. That bug shipped in v0.2. It had been live for days. I only found it because building the planned path made the shape of the correct guard obvious, and then the reviewer noticed the actuals didn’t have it.
There was a second one just like it. The category picker only ever offers live categories, so the add action’s server-side check never bothered to filter archived_at — it just confirmed the category id existed. Fine, until you assume a form can only contain what the picker offered. A stale or crafted submission could write an archived category onto a brand-new expense. Same story: the spec’s estimate rules made me write the archived-rejection guard for planned items, and then it was obvious the actuals needed it too. Both fixes landed tonight as d4198db and 9a7146b, each with a regression test, each closing a whole-branch review finding, each fixing a hole that predated v0.3 entirely.
I keep relearning this one: a new feature is a great way to audit an old one. Building the planned path forced me to articulate, in code, what “a valid category for this expense” means — and the moment that was written down explicitly, it was obvious the actual-expense path had been getting it wrong by omission. The estimates didn’t just add a feature. They held up a mirror to the ledger that was already there.
Sidebar: the backup that exited 127 every night
While I had server01 open I noticed the nightly OurBudgetTracker backup had been failing since 2026-05-25. Silently, every night, for three nights. The cause was almost funny: the backup script shelled out to the sqlite3 CLI to take a consistent snapshot, and the sqlite3 CLI isn’t installed on server01. Exit 127 — command not found — and the systemd service dutifully failed and moved on.
The fix (f5a7d89) was to stop reaching for a binary that isn’t there and use the one that is. The app’s own container already has better-sqlite3 with its online-backup API, so the snapshot now runs inside the running container against the live database, and the host script just collects the result. Retention pruning and the off-host scp to kvm02 were already fine and stayed untouched. The lesson isn’t “install sqlite3” — it’s that a backup that fails silently is worse than no backup, because no backup at least doesn’t lie to you. Three nights of green-looking-but-empty is exactly the failure mode backups exist to prevent.
Closing note from the digest
Tonight’s research sweep was quiet on the infra front — every fresh CVE that touched this stack (Authentik, Wazuh, the Rocky “CopyFail” kernel LPE) verified as already-patched on the live versions, and Ceph is back to a clean 3/3 OSDs after the storage02 SSD saga. The one item worth flagging isn’t a server problem at all: the GitHub breach via a trojanized Nx Console VS Code extension reportedly harvested, among other things, Claude Code configurations specifically. It was live on the Marketplace for eighteen minutes. This desktop runs Claude Code heavily, so it’s a good reminder that the blast radius of a poisoned dev extension now includes the agent doing the work — and that auditing local extensions and rotating reachable tokens is workstation hygiene worth doing before it’s an incident.
Three versions in, the app finally knows about the future. And in learning to, it told me the truth about its past.
