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
The Money Diary dashboard — balance and spend summary cards above date-filtered charts and a recent-transactions table.
Stack
- TanStack Start
- React
- PostgreSQL
- TypeScript
- Drizzle ORM
- Tailwind CSS
- OpenRouter
- Vercel
- Pusher.js
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.
A normal day starts on the dashboard — balances are right there in the account cards. From there it's either a photo or a sentence: snap a receipt and let the assistant OCR it into a transaction, or just type what happened. Anything that looks like a loan or a shared expense, I still check against the actual contact myself before trusting it — the AI is built to handle that on its own, I just haven't leaned on it enough in daily use to fully trust it yet.
Problem
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 me to keep a written spending diary — pen and paper, or a plain notes file. I didn't build this instead because a written diary doesn't work; I built it because I 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 I 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 I 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 — I 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
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.
The AI assistant existed first — the tool calls and prompt building were already wired up before the domain doc did. The doc came about a week later, once the savings withdrawal feature made it obvious that "money moving" isn't one thing: a withdrawal from savings, a payment to a friend, and a transfer between your own accounts are all superficially the same action but need to be categorised completely differently. Writing the rules down was less about a bug and more about not trusting myself, or the model, to keep re-deriving that distinction correctly from memory every time.
Architecture
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
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
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.
The rule that the AI can only ever see and change your own data went in early — before the chat feature even had its full set of guardrails around spam and abuse, which arrived over the following two weeks. The most concrete thing that actually went wrong wasn't the AI inventing something false — it was more mundane: transactions logged through chat were landing with the wrong time attached, because the assistant wasn't accounting for which timezone the person typing was actually in. That got caught and fixed once it showed up in testing.
Loans turned out to be the harder feature to get right. The other side of a loan needs to see it too — but that meant showing someone an entry on your ledger before they'd actually agreed to be connected to you at all, which opened a door for someone to add themself to your finances just by knowing your email. The fix was to gate that visibility on acceptance: the other person only sees anything once they've confirmed the connection, not the moment it's created.
Development
Solo. Every commit on the repo is mine, and the work is mine 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.
Built in small daily sessions, an hour or two most days, because I was genuinely invested in it rather than treating it as a side project to squeeze in. It started as a plain tracker — the entities, the ledger, the basics of logging a transaction by hand. The AI came later, once it was clear that a normal person wouldn't stick with the app if adding or finding something took more than a sentence. The assistant wasn't the starting idea; it became the fix for the part of the product that would have made people quit.
Results
It's live at money-diary.zainharoon.com, running on Vercel, with the recurring-transactions cron firing daily.
I use it myself whenever I spend, want to track something, or add to a wishlist — it's where that lives now. I asked a few friends to try it too, and there are currently six or seven people using it.
Lessons learned
Money Diary made me far more deliberate than a normal app would have. The assistant runs on a detailed set of written rules about what it's allowed to touch, and everywhere it can act is checked against who's actually logged in, so it can only ever see or change that one person's data. Building it taught me a lot about actually working with AI day to day — how to give it context it can act on reliably, how to wire it into a real product instead of a demo, and how to write instructions plainly enough that it behaves the way an actual person would expect.
Right call, and I'd make it again. I'm not a Next.js person by default — I'll use it if a project genuinely needs it, but the framework keeps shipping in a direction I'm wary of, and a recent security vulnerability tied to server actions was a reminder of what that costs when it goes wrong. TanStack Router was the deciding factor: it's a genuinely well-shaped router for React, in the same spirit as Vue Router, built by Tanner Linsley — creator of TanStack — whose work I already rated highly. I could have reached for something like SvelteKit instead, but React's tooling is deep enough that staying in it was the more practical choice.
What I got wrong the first time is on record earlier in this page, and it's worth naming as a pattern rather than a list. I built the assistant before I wrote the rules down — the tool calls were wired up about a week before the domain doc existed — and every early defect traces back to that ordering. The timezone bug happened because "when did this transaction happen" hadn't been pinned down as a rule the assistant had to satisfy. The loan-visibility hole happened because "who is allowed to see whose ledger" was being decided feature by feature instead of stated once. None of it was hard to fix; all of it would have been free to avoid. Next time the spec of what the model is allowed to mean comes first, and the tooling gets built against it — because the expensive part of an AI feature isn't the wiring, it's discovering, one bug at a time, the rules you hadn't admitted you were relying on.