Skip to main content
CryptoFlex// chris johnson
Shipping
§ 01 / The Blog · Ledgerly

Ledgerly: A Personal Finance App That Never Sends My Bank Data Anywhere

I wanted a personal finance app that molds to how I actually track money: any bank's CSV or Excel export, real categorization, budgets and goals, and an AI I can ask questions of my own spending. Ledgerly is what I built: local-first, one SQLite file on my machine, no cloud, and AI that runs through the Claude CLI I already have installed. This is part one of the series, covering the use case, the build, and two bugs a layman QA gate caught before I started running v1 against my real money.

Chris Johnson··17 min read

Every personal finance app I've tried wants two things before it will categorize a single transaction: my bank login and a subscription.

I built Ledgerly instead: local-first, its database sitting on my own machine, importing whatever CSV or Excel export my bank hands me, and doing its AI work through the claude CLI I already pay for, no API key required.

Why My Money Doesn't Live in Anyone Else's Database#

What I wanted was an app that molds itself to how I track money instead of asking me to adapt to a vendor's workflow. Import any bank's export. Categorize every transaction without typing the same merchant name into a dropdown for the hundredth time. See spending as something visual, set budgets and goals, ask an AI questions about my own money, and export a report I can actually hand someone.

Plenty of apps do some of that. What none of them do is stay on my machine. Ledgerly is local-first: one SQLite database file, no cloud account, no vendor holding my transaction history in a warehouse I'll never see. The AI work runs through my existing Claude Code subscription, spawning the local claude CLI headless, so there's no separate API key sitting in a config file somewhere.

What does local-first mean here?

It means the app and its data live entirely on the computer running it. No sync service, no server I don't control, no account to create. If I delete the SQLite file, nothing else in the world remembers my spending.

One more constraint shaped almost everything downstream: the codebase had to stay shareable. I wanted to be able to hand Ledgerly to family or friends who might want to run their own copy, which meant the repository could never contain a trace of my actual bank data.

What Ledgerly Actually Tracks#

Open Ledgerly and the dashboard is the first thing I see: a spending donut for the month, a six-month trend line, budget bars per category, and whatever goals I've set up. Every number in this post's screenshots is synthetic fixture data, none of it my real spending, so I'll say that once here instead of caveating every image.

Ledgerly dashboard for January 2026 showing a spending donut totaling $1,926.40, a six-month trend, budget bars including an over-budget housing category in red, an Emergency Fund goal, and top merchants

That January dashboard totals $1,926.40, with the housing bar in red because it's over budget. Transactions get their own list, where transfers between my own accounts show up muted instead of counted as spending. Budgets and goals are separate tabs, each doing one job: budgets track a category against a monthly ceiling, goals track a savings target against contributions over time.

Ledgerly Budgets tab showing the housing category over budget by $950.00

Ledgerly Goals tab showing an Emergency Fund goal with its contribution history

These screens agree with each other because every aggregate, whether it feeds the dashboard, the chat, a report, or an export, filters through one shared module. Transactions must belong to a committed import batch, must not be excluded, and must not be categorized as a transfer, and spending figures additionally drop income. Expenses are stored as negative cents, so spending comes back as positive net spend and refunds net against charges automatically instead of me having to remember to subtract them somewhere.

The transactions table plus a committed-batch predicate feed one aggregates module; the dashboard, chat context, reports, and exports all read from it, so they cannot disagree.

One Predicate, Every Screen

Every screen that shows a number reads from the same aggregates module with the same filter. That's deliberate: the dashboard, the chat, the reports, and the exports can't disagree with each other, because there's only one place that computes the number in the first place.

Turning the Numbers Into Something I Can Print#

Reports take the same aggregates and turn them into something I can hand to someone or file away. Pick a month, and Ledgerly builds a category breakdown, a total, and a short caption explaining what got excluded and why.

Ledgerly report preview for January 2026 showing an exclusion caption under the total and $950.00 over in the category table

Ask for a narrative summary and an AI writes a few paragraphs grounded in that same report data, plus a short list of suggestions.

Ledgerly AI-generated report narrative with grounded spending numbers and visible truncation ellipses on two long suggestion items

Two of those suggestion items end in an ellipsis, cut off mid-word. That's an honest v1 edge: the narrative's length clamp doesn't check for a word boundary before it cuts. Print gets its own stripped-down view with the same numbers, because that's what becomes the PDF export.

Getting My Own Data In#

Every bank exports data differently, and none of them export it the way Ledgerly's schema expects. The import pipeline closes that gap by running every file through the same sequence before anything reaches a table I actually query.

A file comes in as CSV, parsed with papaparse, or as an Excel workbook, parsed with exceljs. Ledgerly computes a header signature from the column names, checks whether it's seen that signature before, and asks the AI to propose a column mapping if it hasn't. Every row then gets normalized, which is where a bad row lands in an error list instead of silently disappearing. Accounts get resolved or created, duplicates get caught by exact hash and a near-duplicate check, and each row runs through categorization before landing in a staging table for review.

Every import runs upload, parse, header signature, a three-way mapping decision, normalize, account resolution, and dedup before it reaches the categorization precedence ladder, then stages for review and commits atomically.

The AI Guesses the Columns#

The mapping screen shows the AI's proposed column assignments against live preview rows, including whether it read a column as an expense or a deposit.

Ledgerly AI column-mapping screen with live-mapped preview rows showing expense and deposit sign previews

When I ran my actual Copilot Finance export through this screen for the first time, the AI mapped all thirteen columns correctly on the first try. It even figured out that the export stores expenses as positive numbers and needed negation to match Ledgerly's internal convention. I'd braced for a manual correction pass and didn't need one.

Seven Rungs to a Category#

Categorization runs as a strict precedence ladder, first match wins:

  1. source_map: a category already present in the file
  2. source_map_ai: the AI resolves a file category name Ledgerly doesn't recognize
  3. transfer_pattern: a deterministic match against transfer description patterns
  4. refund: matched by account, merchant, and amount against an earlier charge
  5. rule: one of my own saved merchant rules
  6. ai: AI categorization by merchant, when nothing else matched
  7. none: uncategorized

Ledgerly stores which rung actually resolved each row, so I can see later whether a category came from the file itself or from a guess. The review screen makes that visible: file categories the AI matched to Ledgerly's own categories show up with a Remember checkbox, and every transaction still gets a per-row dropdown in case the ladder got it wrong.

Ledgerly import review screen with category mappings from the file matched by AI, Remember checkboxes, and ten transactions with per-row category dropdowns

Trust the File Before the Model

The ladder puts deterministic sources ahead of AI guesses on purpose. A category the bank already assigned, or a rule I saved myself, is more trustworthy than a model's best guess at a merchant name. The AI only gets a vote once everything more certain has already had a chance to answer.

Four Milestones, One Agent Team#

Ledgerly came together across four milestones: M0 built the scaffold, the schema, and the AI adapter; M1 the import pipeline and the transactions page; M2 the dashboard, budgets, and goals; M3 chat, reports, exports, and polish. Twenty-five tasks total, planned up front.

I didn't write most of the implementation by hand. A captain session orchestrated a fresh implementer subagent and a fresh reviewer subagent per task, each briefed with a specialist role prompt depending on what the task touched: finance-expert, ux-designer, or db-engineer. Every task had to clear a spec-compliance gate and a quality-review gate, with fix loops between implementer and reviewer until the review came back clean.

The captain dispatches a fresh implementer and a fresh reviewer per task, cycling between them until review is clean; specialist role prompts brief the implementer, and a layman-QA gate guards every milestone's merge to main.

What's a Layman QA Gate?

Before each milestone could merge, a separate agent walked the running app the way someone with no engineering background would: clicking through screens, trying obvious things, and writing down anything confusing or wrong in plain language.

That gate scored M1 at 5 out of 5, M2 at 4 out of 5 with no blockers, and M3 at 3 out of 5 with two reported blockers, which turned into the two war stories below. A final whole-branch review before merge found four more fixes, none of them critical.

The stack underneath is Next.js 16.3.0 and React 19, better-sqlite3 and drizzle-orm for the database layer, Tailwind v4 with shadcn for the interface, Recharts for charts, papaparse and exceljs for imports, and zod for validation. pnpm check runs typecheck, lint, the full test suite with coverage, and the build, and it's what I run before anything gets called done. That suite is 760 unit and integration tests at 95% statement coverage against a configured floor of 80%, plus 41 Playwright specs: 37 in the main suite and 4 in a separate no-AI suite that exercises what the UI does when the CLI isn't there.

The AI That Runs Through a CLI, Not an API Key#

Four features in Ledgerly touch AI: column mapping, categorization, chat, and the report narrative. All four funnel through one adapter module instead of each rolling its own integration.

Column mapping, categorization, chat, and report narrative all funnel through one adapter module that spawns the local claude CLI as a subprocess — subscription-billed, no API keys — with a mock rail and a disabled rail on either side.

The adapter exports isAvailable(), runJson<T>(), and runStream(). Structured calls, the kind that need a typed result back, build an argv like this:

ts
["-p", prompt, "--output-format", "json", "--json-schema", schema, "--max-turns", "1"]

Streaming calls, for chat, build a different one:

ts
["-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--max-turns", "1"]

No API Key, No Standing Credential

Every AI call in Ledgerly spawns the local claude CLI as a subprocess and bills against my existing Claude Code subscription. There's no API key to generate, store, rotate, or accidentally commit. If the CLI isn't installed or isn't authenticated, isAvailable() reports that honestly instead of the app pretending it has a working connection.

Two environment variables cover testing: LEDGERLY_AI_MOCK=1 returns canned responses instead of calling the CLI, and LEDGERLY_AI_DISABLED=1 makes every AI feature report itself unavailable, which is what exercises the no-AI code paths in the UI. Between them, CI and the local test suite never spend anything real.

Building the adapter surfaced two quirks before either reached a test suite: z.toJSONSchema emits a $schema key the CLI rejects outright, and the CLI refuses a bare-array JSON schema, so the adapter wraps one before the call and unwraps it after. A trivial schema-constrained call also measured around twelve seconds of latency. That's what spawning a full model turn costs, every time, and it's why the mock variable matters as much as it does.

The Chat That Was Certain the Data Wasn't There#

During the M3 layman QA pass, the reviewer asked the chat what I spent the most on in January 2026. The assistant answered, with total confidence, that its records only covered July and August 2026. That was flatly wrong: the database held committed transactions from January through October 2026.

Nothing was wrong with the data. The chat's context was built from the current month and the prior month's summaries, nothing else, and the prompt never told the model that what it was holding was a window rather than the whole picture. So the model did the only reasonable thing available to it and read the absence of January data as evidence January data didn't exist.

The first fix added a data-coverage line to the context, backed by a new getActivityBounds() helper that returns the earliest and latest committed month plus a row count, and a prompt rule alongside it: never claim data doesn't exist beyond the loaded window, and point at Reports instead for anything outside it. One deliberate choice inside that helper: it counts excluded rows toward the bounds, because an excluded row is still evidence that records exist for that month.

Then the final whole-branch review caught the inversion I hadn't seen coming. A coverage span is just that, a span, so now the model could confidently claim records existed for any month inside it, including months in the middle of the range with nothing recorded. Telling it not to under-claim had taught it to over-claim. The second fix softened the wording: records may exist for a given month inside the span, not that they definitely do.

Ledgerly chat conversation showing the pre-fix wrong answer that records only cover July and August 2026, followed by the same question asked again after the fix, where the assistant corrects itself out loud

That screenshot is the same conversation twice: the wrong answer, and then, after the fix, the same question landing a self-correction: "To correct what I said a moment ago: your imported history actually does span January 2026 through October 2026..."

State the Window, Both Directions

Telling a model what it might be missing fixes one failure mode. It doesn't fix the mirror image, where the model now over-trusts anything technically inside the range you described. Both directions need to be stated explicitly, because the model will only guard against the one you warned it about.

Chasing a Data-Loss Bug That Wasn't One#

The second blocker from that same gate read like the worse bug of the two: a transaction with a missing amount had, in the reviewer's words, silently vanished during import. Silent data loss is the kind of report that should stop anyone building a finance app, so before dispatching a fix to anyone I ground-truthed the pipeline myself with a throwaway probe script pointed at the staging table. It reported row count in, row count out, and the full text of whatever error the one flagged row carried, and those three numbers and a string settled it in under a minute.

Nothing was lost. The missing-amount row was exactly the row the pipeline had already flagged as an error, at row index 4, with the message Could not parse "" as a dollar amount. The blank line the reviewer suspected had eaten a different row was dropped by design, the way blank lines are supposed to be dropped.

What had actually gone wrong was attribution. The review screen's error panel showed the error message and nothing else: no date, no description, nothing identifying which row it belonged to. A careful reader looking at a bare error sitting next to a blank line does the obvious thing and blames the wrong row.

The fix extended RowError with the row's date and raw description, threaded through the import flow into the review screen. Every failed row now shows its source row number, its original date and description, and a plain-language reason, and the empty-amount message itself got fixed at the source to read "The amount is missing" instead of the cryptic parse failure.

The lesson I kept from this one: ground-truth a bug report against the actual system before dispatching a fix, or you'll fix the wrong thing correctly and never know it.

Everything Else That Almost Went Wrong#

A few smaller problems turned up along the way, each worth a paragraph.

playwright.config.ts had baseURL and webServer both pointed at port 3210, the exact port my real-data dev server runs on, with reuseExistingServer: true. Any Playwright run started while that dev server happened to be up would have silently adopted it and driven every spec against my actual database instead of a test fixture. I caught it by reading the config, not because anything failed. The fix gave E2E its own port and disabled reuse, and along the way I learned Next 16's dev-server lock is keyed to the project directory rather than the port, so an interactive dev server and the E2E suite can't share one checkout no matter how the ports are separated.

A 200-row staging request once returned success after 31.2 minutes, and the database that came out the other side had 13 accounts, zero batches, zero transactions, and a clean integrity check. A SIGKILL reproduction against a 2.35MB uncheckpointed WAL recovered fully, which cleared the transaction layer of blame, and the root cause was never formally proven. The fix was defense in depth anyway: a verify-readback before reporting success, a one-shot absolute-path log, and a guarded checkpoint. It hasn't recurred, including through the real 10,613-row import.

SQLite's documentation says the bound-variable limit is 32766. The binary this app actually links said 2000, which I found out when the real import blew up a multi-row insert with SqliteError: too many SQL variables. My fixtures topped out at 15 rows and never came close. The fix measured the actual limit by binary search instead of trusting the documented number, and that's the only reason it worked: my own first suggestion of 400-row chunks would also have failed. It ships at 150 rows per chunk, roughly 1,950 bound variables.

The strangest one to track down: importing a value from a module that transitively imports better-sqlite3 into a "use client" component 500s five routes at once, invisible to the type checker and the linter both. Only running the app end to end caught it. The fix computed the value server-side and passed it down as a plain boolean prop, leaving the client component importing types only.

Keeping My Real Bank Data Out of a Shareable Repo#

The shareability constraint from the start of this post turned into its own small discipline. data/ is gitignored, but with a tracked placeholder so the directory itself still exists in a fresh clone:

txt
data/*
!data/.gitkeep

That's data/* rather than data/, deliberately. Git can't re-include a file inside a directory that a directory-level pattern already excluded, so the wildcard form is what actually makes the negation work. All the test fixtures are synthetic but mimic my real export's thirteen-column shape, and a privacy audit in the check suite verifies that zero files under data/ are tracked and no real-data traces have leaked into anything tracked.

A fresh-clone drill, running the whole setup from a clean checkout instead of my working copy, found a real bug nothing else had caught: pnpm db:migrate crashed immediately because the gitignored data/ directory didn't exist yet. The running app creates its own data directory on startup, so my long-lived working copy had never once hit the code path a fresh clone hits first. The fix added a recursive mkdir to drizzle.config.ts.

The real test of all of this was importing my actual Copilot Finance export, 10,613 transactions, through the app's own import flow into the local gitignored database. It auto-created 13 accounts, stored 18 file-category mappings and 639 merchant rules, and staging took 33.8 minutes under instrumentation. The AI got the column mapping right on the first try, all thirteen columns, sign inference included. None of that data, or anything derived from it, ever touched a tracked file.

The Build on One Page#

The whole story, condensed to a single image:

Infographic summarizing the Ledgerly v1 build: 1 SQLite file, 0 API keys, 10,613 transactions imported, 760 tests, 95 percent coverage, 41 Playwright specs, QA gate scores of 5, 4, and 3 out of 5, and a five-step flow from import to reports

Where Ledgerly Goes From Here#

Ledgerly v1 is merged, and it's what I run against my real data now. v2 is a locked backlog of functional fixes: a visible marker for aborted chats, per-month coverage for the chat's context instead of a single span, typed import errors, component tests where the suite is thin. It's the list a working app accumulates once you actually use it instead of just building it.

v3 is reserved entirely for UX and UI design. Functional correctness and visual design pull in different directions when they're worked at the same time, and sequencing them apart means each pass can actually finish instead of half-finishing both.

This is part one of the Ledgerly series. Part two picks up the v2 backlog and whatever else the next few weeks of actually using this app turn up. I don't know yet what that list looks like beyond what's already written down, and that's kind of the point of writing it as a separate post instead of predicting it here.

Related Posts

Editorial redesign, claude.ai/design to Claude Code pipeline

Anthropic launched claude.ai/design the same week I'd been sketching a new look for this site. I did the design work in the browser, exported the handoff bundle, pointed Claude Code at it, and got a production rebuild six phases later with zero new npm dependencies.

Chris Johnson··15 min read

How I built a full direct-booking vacation rental website for my brother-in-law Terry using Claude Code and a 6-agent AI team: Next.js 16, Supabase, Resend, and a surprisingly capable admin dashboard for a non-technical host.

Chris Johnson··22 min read

How I cataloged 13 custom agents, 23 learned skills, 10 rules, 8 bash scripts, and 2 MCP servers into a searchable, filterable showcase page for my blog. One session. One new route. One background research agent doing the heavy lifting.

Chris Johnson··10 min read

Comments

Subscribers only — enter your subscriber email to comment

Reaction:
Loading comments...

Navigation

Blog Posts

↑↓ navigate openesc close