Blog
How to build an expense tracker project that gets the money right
An expense tracker project is decided by four choices: store money as NUMERIC(19,4) and pass it as strings, model a transfer as one row rather than two, enforce per-user isolation in the database with row-level security, and verify totals against fixtures whose answers are known independently.
By Anup Maurya and Chaitanya Vartak ·
The part everyone builds first, and the part that decides it
An expense tracker is the most-built portfolio project there is, which is why most of them look the same: a form, a list, a pie chart, float amounts, done in a weekend.
The interesting version is not harder to build. It just makes four decisions differently, and each one is the decision a reviewer, an interviewer or your future self will actually poke at. This is what those four are, written from having built one.
1. Money is not a float — decide this before the first migration
0.1 + 0.2 is not 0.3 in any IEEE-754 language. Your project is JavaScript, Python or Java; all three do this.
For one coffee it is invisible. For a thousand transactions it is a total that does not match the rows it was built from, and a bug report you cannot reproduce because it depends on which rows are in the sum.
The fix is boring and total:
- In the database,
NUMERIC(19,4)— neverfloat8, neverreal, nevermoney. 19 digits of precision, 4 decimal places, which covers currencies with three decimals and intermediate rates. - In the API, amounts cross the wire as strings.
"12.50", not12.5. The moment a decimal touches JSON's number type it is a float again, whatever the database said. - In the client, a decimal library.
big.jsis 6 kB. Represent an amount as{ amount: string, currency: string }and make raw arithmetic on it a type error.
The rule that makes this hold: no number ever holds an amount. Not "we round at the end". Rounding at the end is what hides the drift until the day it is large.
The longer argument, with the failing cases, is here.
2. A schema that survives the second feature
The naive schema is one transactions table with a type column. It works until you add transfers, and then it breaks, because a transfer between your own accounts is one event with two sides and is neither income nor an expense.
The shape that holds:
| Table | Why it exists |
|---|---|
accounts | Balance is derived, never stored. Each has its own currency and a flag for whether it counts toward net worth. |
categories | Flat, and deliberately so. Hierarchies read well in a demo and are a pain in every query afterwards. |
transactions | type is income, expense or transfer; account_id, and to_account_id for transfers only. |
budgets | Scoped to a set of categories and a period. Spent is computed, not stored. |
planned_payments | A rule (amount, interval, next date), not a row per future occurrence. |
loans | A running balance per counterparty, with repayments as child rows. |
Two rules worth writing down:
- Never store a balance. Store transactions and derive it. A stored balance is a cache, and it will be wrong the first time a write half-fails.
- A transfer is one row, not two. Two rows means every report has to recognise and exclude the pair, and one of them will forget.
Cross-currency transfers store both amounts, because the rate your bank actually used is the rate that happened, not the rate an API returns tomorrow.
3. Multi-user means row-level security, not a WHERE clause
If your project has accounts, the question a reviewer will ask is "what stops user A reading user B's rows?" — and "every query includes WHERE user_id = ..." is not an answer, because it takes one query that forgot.
Enforce it in the database. In Postgres that is row-level security: a policy per table that compares the row's owner against the authenticated user, so an isolation bug requires a policy to be wrong rather than a developer to be careless. Then test it by trying: sign in as one user and request another's row by id. It should 404, not 403, and certainly not 200.
4. Verify the arithmetic against known answers, not against itself
The mistake is writing tests that assert what the code currently does. That passes forever and proves nothing.
Instead, build a small set of fixture datasets with the answers already known — a month of transactions and the exact income, expense, balance and per-category totals it must produce, worked out independently. Then the tests assert the answers, and a refactor that changes a total fails immediately.
Worth putting in the fixtures, because these are where money code actually breaks:
- A month-end date that does not exist in the next month (31 January + 1 month).
- A period boundary at midnight in a timezone that is not UTC.
- An account with a missing exchange rate. It should contribute zero and say so — never fall back to 1:1, which quietly inflates net worth.
- A long list of small amounts whose float sum differs from the decimal sum. This is the one that catches a regression to
number.
What to skip
For a portfolio project, these earn nothing and cost weeks:
- Bank sync. Aggregator APIs need a registered legal entity and a compliance review. Import a CSV instead — it demonstrates the same data-mapping work and you can finish it.
- SMS or email parsing. Platform permissions, endless bank-specific formats, no interesting logic.
- A language model. "AI insights" over ten rows of test data is a paragraph a model made up. Deterministic statistics over the user's own rows are more useful and can be checked.
- Microservices. One database and one deployable. Every reviewer knows why.
What to show instead
Three things make an expense tracker project look built by someone who has shipped:
- The money type. Show the string-based amount and the decimal column, and say what breaks without them.
- Import and export. A CSV importer with a column mapper, and a full export that re-imports. It proves you thought about the user leaving, which almost no student project does.
- One correctness story. A single bug you found because a fixture disagreed with the code. That is the difference between a project that works and a project you can talk about for twenty minutes.
Chillar's is a working version of all of the above — a free expense tracker on the web, with exact decimal arithmetic end to end. If you are building your own, the feature pages describe what each piece does, and the money-is-not-a-float post has the arithmetic in detail.
Frequently asked questions
- What should an expense tracker project use for amounts?
- A decimal type end to end: NUMERIC(19,4) in Postgres, strings over the API, and a decimal library such as big.js in the client. Never a float, and never a JSON number, because JSON numbers are floats whatever the database column said.
- What tables does an expense tracker need?
- Accounts, categories, transactions, budgets, planned payments and loans. Balances are derived from transactions rather than stored, a transfer is a single row carrying a second account reference, and planned payments are a rule rather than a row per future occurrence.
- What should an expense tracker project skip?
- Bank synchronisation, SMS parsing, a language model and microservices. Bank aggregators need a registered entity and a compliance review, SMS parsing is endless bank-specific formats, generated insights over test data are invented, and one deployable is easier to defend than five.
Related reading
- Why an expense tracker must not store money as a float0.1 + 0.2 is not 0.3 in binary floating point. What that means for an expense tracker, where the error shows up first, and what Chillar's does instead.Read
- How to switch expense trackers without losing your historyMove years of history to a new expense tracker without losing it: export first, check the CSV columns, map them once, then verify three balances by hand.Read
- A free expense tracker template you can import laterA free monthly expense tracker template in CSV, the eight columns every tracker needs, and the point at which a spreadsheet stops being worth keeping.Read
About the authors
Anup Maurya · Chaitanya Vartak
Anup Maurya and Chaitanya Vartak build and run Chillar's. Everything published here is checked against the app's own behaviour before it goes out.
Start tracking in about two minutes
Free to use, and everything you enter is exportable. No card and no trial.