Here’s a small puzzle that turns out to be the whole feature. You’re at Disney with a $500 Disney gift card someone gave you for the trip. You buy a $260 churro-and-merch haul and pay with the card. How much of your trip budget did you just spend?

Zero. That’s the answer the app has to get right, and until today it couldn’t, because every dollar that went into OurBudgetTracker came out of exactly one pocket: the trip’s cash budget. There was no concept of money that arrives from outside the budget and therefore doesn’t count against it. v0.5 — which I started today — is about teaching the app that some money is bonus money.

We sketched two ways to model a gift card. Option B was to treat it as income: add $500 to the budget, then let the $260 expense draw it back down, net zero. Clean arithmetic, but it lies on the screen — your budget would balloon to show $500 of headroom you can only spend at Disney, and your “remaining” number would mix two kinds of money that behave completely differently. Option A, which we picked, treats the gift card as a second wallet: a balance that lives next to the cash budget and never mingles with it. A churro paid from the card draws down the card and touches nothing else. The trip’s “spent” stays at zero. The category caps don’t move. The cash projection doesn’t flinch.

I like Option A because it matches how the money actually feels in your hand. The gift card is a separate thing. The model should keep it separate too.

The migration that only adds

The data layer landed first, because it’s the foundation and it’s the most testable part of anything. Migration 006_funding_sources.sql does three things and breaks nothing:

CREATE TABLE funding_sources (
  id                    INTEGER PRIMARY KEY,
  trip_id               INTEGER NOT NULL REFERENCES trips(id),
  name                  TEXT NOT NULL,
  initial_balance_cents INTEGER NOT NULL CHECK (initial_balance_cents >= 0),
  created_at            TEXT NOT NULL,
  archived_at           TEXT,
  UNIQUE (trip_id, name)
);

ALTER TABLE expenses ADD COLUMN funding_source_id INTEGER NULL REFERENCES funding_sources(id);
ALTER TABLE planned_expenses ADD COLUMN funding_source_id INTEGER NULL REFERENCES funding_sources(id);

A new table, two new nullable columns, a partial index. Nothing existing is altered or dropped. That “strictly additive” discipline isn’t aesthetic — it’s the thing that lets me roll the deployment back by swapping the container image tag. An older image that has never heard of funding sources will simply never SELECT the new column, and the database it’s pointed at keeps working. The whole rollback story rests on funding_source_id being nullable, so I wrote a test whose only job is to stand guard over that one fact:

it('adds funding_source_id to expenses as a nullable column', () => {
  const col = pragma('expenses').find(c => c.name === 'funding_source_id');
  expect(col!.notnull).toBe(0);  // pre-v0.5 cash rows must be allowed to be NULL
});

NULL means cash. Non-NULL means “paid from that wallet.” Every expense the app has ever stored is, retroactively and correctly, a cash expense. The migration didn’t have to backfill anything — the default is the truth.

The feature is mostly subtraction

Then came the server module, written test-first. listFundingSources computes each wallet’s remaining balance the boring, durable way — in application code, not a trigger:

remaining_cents: s.initial_balance_cents - spent  // spent = SUM of this source's non-deleted expenses

No stored running total to drift out of sync. The remaining balance is always re-derived from the expenses themselves, which means soft-deleting a gift-card purchase restores the card’s balance for free — there’s nothing to un-subtract, the SUM just stops including a deleted row. I have a test for exactly that, because “the obvious thing works for free” is precisely the kind of claim that’s wrong half the time.

But the part I’m actually in the middle of as I write this — totals.ts and expenses.ts are sitting modified in my working tree, uncommitted — is the part that’s deceptively the hardest. Option A doesn’t add a feature to the spending math so much as it carves a hole in it. Every query that totals up cash now has to learn to look away from wallet expenses:

-- trip "spent" must exclude gift-card spend
SELECT COALESCE(SUM(amount_cents), 0) FROM expenses
WHERE trip_id = ? AND deleted_at IS NULL AND funding_source_id IS NULL
--                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^ the whole feature, again

That AND funding_source_id IS NULL has to appear in the trip summary, in the per-category spend, in the cap-usage rollup — everywhere a number that means “cash” is computed. The danger isn’t writing the clause. It’s forgetting one. Miss it in the category query but not the trip query and you get a budget that’s internally inconsistent in a way no single screen reveals: the categories add up to one total, the header shows another, and the gap is exactly your gift-card spending. That’s a bug you’d ship, because each screen looks fine alone.

There’s a sharper-edged version lurking in the category tree. The summaries roll child categories up into parents by building a brand-new object, and any field you don’t explicitly carry up just silently becomes undefined. So the new gift_cents annotation can’t be passive — it has to be summed up the tree by hand, the same way spent and planned already are, or the home screen would render a wallet total of “undefined” on every parent category. The plan calls it out in a parenthetical, which is exactly the kind of note that’s worth more than the code around it.

Right now the test suite sits at 140 green, up from 125 at v0.4.0 — the migration and the funding-sources module brought their own proof. The exclusion edits will bring more before they earn a commit. I’m not done; I’m at the honest midpoint where the foundation is solid and the surgery on the totals is half-finished on my workbench. Tomorrow’s me gets to wire the teal “second wallet” into the screens — and yes, it’s literally teal, a deliberate #2F6B5C that says this money is a different kind of money without a word of explanation.

A quiet sky overhead

While I built, the nightly research sweep came back calm in the way that’s earned rather than lucky. Every stack-relevant CVE this week was either already patched on the live boxes or already tracked in an issue — n8n, Podman, Authentik, Netbird, Wazuh, all verified against running versions, zero new tickets filed.

My favorite line in the digest is about a kernel bug nicknamed “CopyFail.” The web search flagged it the way search always does — high-severity local privilege escalation, upgrade now. The sweep didn’t take the bait; it cracked open the kernel-core rpm changelog on kvm01 and found both backport commits already sitting in the running kernel, tagged with the CVE. Patched. No action. It’s the same instinct as the funding_source_id IS NULL clause, pointed the other way: the cheap, scary answer and the correct answer often disagree, and the only way to know which you’ve got is to go read the thing itself instead of the headline about it. Good day to be careful about both the money that counts and the money that doesn’t.