Skip to content

Work 2026

Money Diary

A personal finance tracker with an AI assistant that writes to the ledger — and a security model built on the assumption that the model is never to be trusted.

Year
2026
Role
Solo — design, engineering, infra
Client
Personal project
Links
money-diary.zainharoon.com ↗
Stack
  • TanStack Start
  • React
  • PostgreSQL
  • TypeScript
  • Drizzle ORM
  • Tailwind CSS
  • OpenRouter
  • Vercel
The Money Diary dashboard — balance and spend summary cards above date-filtered charts and a recent-transactions table.

The Money Diary dashboard — balance and spend summary cards above date-filtered charts and a recent-transactions table.

Money Diary is a personal finance tracker: transactions, a savings ledger, goals, a wishlist, payment accounts, and a dashboard with date-range analytics on top of all of it. It runs in production at money-diary.zainharoon.com.

The part worth reading about is the AI assistant. You type “spent 500 on groceries” and a transaction appears — no form, no dropdowns. That means a language model is issuing writes against a financial ledger, which is a genuinely dangerous thing to build unless you decide, up front, exactly how much the model is allowed to be trusted. The answer this codebase settles on is: with the words, and nothing else.

TODO(zain): Two or three sentences a stranger could read to know what this product is — in your voice. What does a normal day of using it look like? Do you open the dashboard, or do you just type a sentence into the assistant and close the tab?

Problem

Real facts, so far: the product models money as five distinct things — a transaction (money moving between you and the world), a saving (money moving into or out of a savings pool), a goal (a target with progress), a wishlist item (something you want to buy someday), and a payment account (the card, wallet, cash or bank it moved through). Keeping those separate is the whole design. A savings withdrawal is not a purchase; a payment to a friend is not a transfer, it is an expense; a transfer is only a move between two of your own accounts. Most trackers collapse those distinctions and quietly produce wrong totals.

People had been telling him to keep a written spending diary — pen and paper, or a plain notes file. He didn’t build this instead because a written diary doesn’t work; he built it because he could already tell it would become hectic to search through or summarise. You can write an entry down, but you can’t ask a notebook a question. That’s the gap the two central pieces of the product answer directly: the AI assistant makes logging a transaction as fast as writing it down — a sentence, not a form — and the same assistant is what you can query and summarise against later, which is the thing a paper diary never lets you do.

The other honest reason is that he wanted to learn how to build it: wiring up an affordable model through OpenRouter, and working out how conversation context actually gets managed once a chat has history. Money Diary is both the tool he wanted and the excuse to learn how to make it.

This is a personal project, not a product — there is no user base, and it was never launched at anyone. It’s practice as much as it is a tool: a way to learn hosting, and how free and paid provider tiers actually behave, by building something real enough that those decisions had consequences instead of staying theoretical. The plans, quotas, admin moderation and OTP sign-up in the codebase exist for the same reason — he built the multi-user version of the thing because that’s the version worth learning to build, not because anyone else has signed up. Single-player in practice; built to the shape of something bigger, on purpose.

Research

Real facts, so far: before the AI assistant could be written, the product’s semantics had to be pinned down precisely enough that a language model could not misread them. A written spec of the domain lives in the repo — what each entity means, what it is distinct from, and where the edges are. Example, verbatim from it: “Payments to other people (even if titled ‘Transfer’ or a person’s name) are expenses, not transfers. Ask once if ambiguous.”

Loans and shared expenses got the same treatment. Splitting a bill records the creator’s full expense (they really did pay the whole thing) plus a matching receivable for each participant. The participant’s side of a split is written so it can never move a balance — that single constraint is what stops a shared bill being counted twice.

TODO(zain): Did you write the domain doc before the assistant, or after the model got something wrong? The answer is a genuinely good story either way — say which it was, and name the case that forced it.

Architecture

Real facts, so far. TanStack Start (Router, Query, DB) with React and TypeScript, server-rendered through Vite; Drizzle ORM over PostgreSQL (Neon serverless in production, a local container for development); Tailwind CSS with shadcn/ui and Recharts for the analytics; Better Auth for sessions; Biome for lint and format. Deployed on Vercel, with a nightly job that materialises recurring rules — subscriptions, salary — into real transactions and advances each rule’s next run date.

The code is organised by feature, not by layer: each domain area — transactions, savings, goals, wishlist, payment accounts, analytics, dashboard, recurring rules, contacts, notifications, billing, admin, auth and the AI assistant — owns its own server logic, hooks, validation and types. The schema runs to roughly two dozen tables, tracked through dozens of migrations.

Loans, splits and the AI tools all write through the same underlying service logic, rather than each path reimplementing the arithmetic. Three entry points, one place where the balance math lives.

The AI assistant

The Money Diary AI assistant panel — a chat thread where a typed sentence becomes a confirmed ledger entry.

The assistant panel — natural language in, a validated ledger write out.

A chat message runs through the assistant pipeline, which builds a system prompt from the product’s rules, loads a bounded window of recent conversation history, and hands the whole thing to a provider client — OpenRouter in production, with Ollama supported for running a local model instead. The model replies with tool calls, and a server-side executor turns those into database writes.

There are twenty tools. They cover creating and updating transactions, transfers, savings, goals, wishlist items, payment accounts and recurring rules; deleting goals and wishlist items; recording a loan; splitting an expense; fetching an exchange rate; and one read tool, which returns capped rows alongside aggregate totals so the model reports real sums instead of estimating them from a truncated list.

Challenges

Real facts, so far — and this is the section the project actually earns.

An LLM must never be allowed to say who it is acting for. The single most important line in the codebase is one that isn’t dramatic to look at: every write is scoped by the authenticated session’s user — never by anything the model outputs. No tool accepts a user-identity argument at all. There is nothing the model can emit, and therefore nothing an attacker can talk the model into emitting, that changes whose ledger is being written to.

This matters because a language model’s output is attacker-influenced data. The user types the prompt. If a tool call could specify whose ledger to write to, then “create a transaction for user 42” is no longer a sentence — it is an authorisation decision made by a text predictor, and the model has become the auth layer. The fix is to make the question unanswerable: identity is resolved before the model is ever invoked, and the tool executor is structurally incapable of accepting a different answer. The API layer enforces the same rule from the other side — a user identifier arriving in a query string or request body at all is rejected outright.

Around that spine, the chat endpoint stacks the rest in order:

  • Session gate — an unauthenticated request never reaches the model.
  • Rate limit — a capped number of chat requests per user per minute.
  • Plan quota — a monthly AI message allowance, consumed atomically before the provider is called, so a runaway loop costs a quota rather than a bill.
  • Injection and abuse screening — incoming messages are matched against known prompt-injection and off-topic patterns. Repeated attempts close the chat for a cooldown period.
  • Schema validation on every tool argument — each tool call’s arguments are validated against its own schema, and the write is refused on a miss. The model proposes; the schema disposes.
  • Ownership checks on every referenced ID — a category, goal, account or contact ID coming back from the model is re-fetched scoped to the session user before it is used. A hallucinated or guessed ID belonging to someone else resolves to nothing.

The through-line is that no single one of those is clever. What makes it work is that the model is treated, consistently and everywhere, as an untrusted client that happens to be good at English.

TODO(zain): Which of these did you build first, and which did you add after something went wrong? Did the model ever actually do something you didn’t expect — hallucinate an ID, insist a payment to a friend was a transfer, double-log a split? Name a real failure and what you changed. A security model presented as if it sprang out fully formed is less convincing than one that has scar tissue.

TODO(zain): Two more non-AI challenges. Candidates from the code, if they ring true: getting savings withdrawals to stay out of the analytics without corrupting account balances; keeping recurring-rule generation idempotent when the cron runs; making the borrower side of a loan visible without letting anyone push rows into your ledger by adding your email. Whichever were actually hard — what broke, what you tried, what fixed it.

Development

Real facts, so far: solo. Every commit on the repo is Zain’s, and the work is his across design, engineering and infrastructure. It ships to Vercel; the database is Neon; the same Postgres schema runs locally in Docker. The repository is private, so there’s no source link on this page.

TODO(zain): How did this actually get built — evenings, weekends, a focused stretch? What was the sequence: did the tracker exist first and the AI come later, or was the assistant the point from day one? And what does your loop look like when you’re the only person reviewing your own pull requests?

Results

Real facts, so far: it’s live at money-diary.zainharoon.com, running on Vercel, with the recurring-transactions cron firing daily.

TODO(zain): No invented numbers here — and there is genuinely no way to know this from the code. Do you use it yourself, daily? Has anyone else signed up, and do you know that or are you assuming? Has it replaced whatever you were doing before? “I’ve tracked every transaction in it since March and it’s the only finance app I’ve kept” is a far stronger sentence than any percentage, and it’s one only you can write.

Lessons learned

TODO(zain): What did giving a language model write access to your own money teach you that a CRUD app never would? Where did you draw the line on what the AI is allowed to do, and is there anything you deliberately refused to let it touch?

TODO(zain): TanStack Start is a young framework and you bet a whole product on it. Was that the right call? What did it give you, what did it cost you, and would you reach for it again on Monday?

TODO(zain): And the question that makes a case study credible — what did you get wrong the first time?