Personal finance aggregator: Account balances and disconnecting banks cleanly - Part 8

Personal finance aggregator: Account balances and disconnecting banks cleanly - Part 8

Building Finance System

~10 min read

The next two pieces of this project both came from the same question: does the app actually reflect the full state of my accounts, in both directions - what's currently in them, and which ones I've actually chosen to keep connected. This post covers pulling real balances into the dashboard, and building a way to disconnect a bank that doesn't leave stale data lying around pretending to be current.

Balances were already paid for

TrueLayer's Data API has balance endpoints - /data/v1/accounts/{id}/balance and the card equivalent, /data/v1/cards/{id}/balance - returning current, available, and currency for a given account or card. The convenient part: the scope needed for this, balance, had been in the OAuth scope string since the very first app setup post, added early specifically on the reasoning that re-consenting later is annoying and the cost of asking upfront is low. That bet paid off in exactly the way it was supposed to - every already-linked bank already had permission granted, and adding balance data to the sync meant one more API call per account, not a re-linking flow for every bank I'd already connected.

The balance pull sits in the same per-account loop the transactions pull lives in, and deliberately doesn't share a failure path with it:

try:
    balance = fetch_balance(access, kind, account_id, logger=logger)
except requests.RequestException as exc:
    logger.warning(f"{label} / {name}: could not fetch balance - {exc}", exc_info=True)
    balance = None

A failed balance fetch just means that account's balance shows as unknown this run - it doesn't touch whether that account's transactions get pulled. Given the resilience work from the previous post was entirely about not letting one kind of failure take down unrelated data, it would have undermined that to let a balance-specific problem silently cost me transaction history too.

Nested accounts, and what "total" should mean

Each bank on the Accounts page now expands to show its individual accounts and cards as child rows, each with its own balance - not just "Monzo: linked," but "Monzo: Current Account £1,161.20, Savings Pot £3,500.00." That part was mechanical once the data existed: group by bank, render a nested list.

The more interesting decision was what the Home page's Total balance stat tile should actually sum. Naively, every linked account and card's current balance, added together. But a credit card's current balance represents money you owe, not money you have - summing it straight into a "total balance" figure alongside actual cash accounts would inflate the number in a way that's actively misleading about how much money you actually have access to. The fix was filtering the sum to accounts, not cards:

totalBalance = accounts
  .filter(a => a.kind === 'accounts' && a.balance !== null)
  .reduce((sum, a) => sum + a.balance, 0)

Card balances are still fully visible, just itemised per-account rather than folded into a headline number that would misrepresent them.

(A correction on what this commit left behind: it kept TrueLayer's card balance as reported - a positive number meaning "amount owed" - and just excluded cards from the total. That worked for the headline figure but left the data in two different sign conventions, a card at +£250 next to transactions where spending is negative. a later fix negated card balances on the way in so debt is negative everywhere; see post 11.) It's a small distinction, but it's the difference between a dashboard number that means something and one that technically sums correctly while telling you something false.

Disconnecting a bank, and what "disconnect" should actually do

Every bank that's ever been linked stayed linked, permanently, until this point - there was no way to remove one short of editing the token file by hand. Adding an "Unlink" action meant deciding what it actually deletes, and the honest answer is: two different things, only one of which should happen automatically.

The connection itself - the stored refresh token, the cached consent status, the cached account/balance snapshot - is unambiguous. The moment you unlink a bank, all of that is stale by definition; there's no version of "unlink but keep the cached data around" that makes sense, because nothing will ever refresh it again. That part happens unconditionally.

The transaction history that bank already contributed is a different question entirely. Maybe you're unlinking because you closed the account and never want to see it again. Maybe you're unlinking a card temporarily and want your spending history intact when you relink it later. Those are different intents, and defaulting to either one silently would be wrong for the other case. So it's a choice, made explicit in a confirmation modal:

<label className="modal-checkbox">
  <input type="checkbox" checked={deleteData} onChange={...} />
  Also delete all synced transactions for this bank
</label>

Unchecked by default - the reversible, lower-consequence option - with deleting history requiring a deliberate extra click rather than being the path of least resistance. The API mirrors that split exactly:

storage.delete_connection(conn, label)
storage.delete_accounts_for_bank(conn, label)

deleted_transactions = storage.delete_transactions_for_bank(conn, label) if delete_data else 0

The first two lines always run - the stale cache goes regardless. The transaction delete is the one line gated on what the checkbox actually said.

Confirming a destructive action needs its own care, even in a modal

The unlink confirmation isn't just a window.confirm() - browser-native confirm dialogs can't hold a checkbox, and the delete-data choice is exactly the kind of decision that shouldn't be a second, separate prompt stacked after the first. So it's a proper modal component: a backdrop, a centred card asking "Unlink {bank}?", the checkbox, and two buttons whose labelling turned out to matter more than I expected. Both the row's trigger button and the modal's confirm button initially just said "Unlink," which looked fine visually - one small ghost button next to the bank, one bold red button in the modal - right up until a test tried to click "the button labelled Unlink" after the modal was already open and found two of them. That's a real signal, not just a testing inconvenience: if an accessibility tool or a screen reader can't disambiguate the two buttons by label either, a sighted user skimming quickly is in the same position. The fix was renaming the modal's confirm button to "Unlink bank" - distinct, still obviously the same action, and now unambiguous to a test and to anyone not looking closely at which one is red.

Testing the confirm flow itself needed one deliberate trick: asserting a loading state exists at all requires not letting the mocked request resolve immediately, or there's no window in which "Unlinking…" is ever actually on screen for a test to catch. A manually-resolved promise - call the component's handler, assert the disabled loading state, then resolve the promise and assert the final state - is the same pattern the sync button's tests used in the previous post, and by this point in the project it had become the default way to test anything with a pending network request in the middle of it, rather than something rediscovered each time.

A schema change that had to not break the running system

Adding the balance table meant a new column (account_count, tracked separately, added around the same time) on a table - connections - that already existed on my running deployment from earlier work. CREATE TABLE IF NOT EXISTS doesn't add columns to a table that's already there; it only handles the case where the table doesn't exist yet at all. Deploying that change without accounting for it would have meant the next nightly sync on the real server crashing with no such column, discovered at 3am with nobody watching.

existing_columns = {row["name"] for row in conn.execute("PRAGMA table_info(connections)")}
if "account_count" not in existing_columns:
    conn.execute("ALTER TABLE connections ADD COLUMN account_count INTEGER")

A few lines, checked on every connection open, and safe to leave in permanently - once the column exists, the check is a no-op. It's not a general migration framework, and it doesn't need to be one for a single-table, single-user app; it just needs to not break the thing that's already running the moment new code reaches it.

What wasn't built yet

Worth closing honestly rather than implying this was finished, because it wasn't. category had sat unused in the transactions schema since the very first SQLite migration back in post four - the intent from day one was rule-based categorisation (grocery-store descriptions become "Groceries," recurring same-amount debits become "Subscriptions," and so on), and it was still just a NULL column. The Home page's "spending by category" chart was still demo data for exactly that reason. And the storage layer was deliberately built provider-agnostic from post four onward - a providers/ package with truelayer.py as the only module in it - specifically so a second source could be added without redesigning anything underneath it. Hargreaves Lansdown, for investment accounts, was the one I kept meaning to get to.

Both turned out to be the next things built, though not quite as planned. Categorisation became post 12. Hargreaves Lansdown didn't end up a CSV import at all - it became a scraper-backed provider with its own credential handling, in post 14.

Looking back at the first arc

Eight posts ago this was two competing evaluation scripts, deciding whether signing my own JWTs with a private key was worth the trouble compared to a plain OAuth flow with a sandbox to test against. By this point it was a FastAPI backend with SQLite storage, session-based auth with optional 2FA (which, as it happens, was about to be deleted), a multi-page React frontend with a design system, a nightly sync job that survives individual accounts having bad days, real dashboard numbers backed by actual SQL, and a way to walk any of it back cleanly if a bank connection isn't wanted anymore.

None of the individual steps were large. What made it hold together was mostly resisting the urge to skip the boring parts - the sandbox before the real bank, the de-dup key before real usage exposed the wrong one, the migration guard before the schema change reached the running server. Every one of those was the unglamorous choice at the time.

That was the end of the first arc, and the series continues from here: the app was about to stop owning its own logins, get a storage tree per user, learn to categorise, and grow budgets and savings projections on top.


Previous: ← Keeping data fresh · Next: Handing authentication to identity-system →