
- Ref
- P-02
- Period
- 2025 - 2026
- Status
- Live
- Role
- Build
A personal finance app for money that repeats. You describe each commitment once (what it is, how much, how often, from when) and the app projects it across the weeks, months and years ahead, then records what actually happened against it.
The problem
Budgeting tools mostly categorise the past: import a statement, sort the rows, learn in arrears where the money went. For commitments that are already fixed, like a salary, rent and standing orders, that work is redundant, because the amounts were knowable in advance. The useful question is whether the period matched the plan: did the direct debit leave at the figure you expected, and how many bills are still outstanding with a week of the month left.
What I built
Four screens over one model. My Budget is the editor: a grid of commitments (description, amount, cadence, start date) with search, filters, sorting and a single save that works out what to create, update and delete. Tracker turns the current week, month or year into a checklist, ticking items off as paid or received and correcting the amount when it differed. Reports sets expected against actual for that period, item by item, with the totals and the difference. Dashboard annualises it into income, outgoings, net and savings rate. Around all of it: email-verified accounts, self-service credential changes, five accent themes with a light mode, and a choice of currency.
Architecture
Django REST Framework over Postgres, with a Vue single-page app on a separate origin. Two models carry the whole app: a BudgetItem is a standing commitment (type, amount, cadence, start date) and a Transaction is one occurrence of it on one date, holding what actually moved and whether it cleared. The API owns every piece of date arithmetic. A request names a type, a cadence and any date inside the period; the server resolves the boundaries itself, creates whichever occurrences that period does not have yet, and returns them joined to their plan. Auth is session cookies with CSRF. Redis holds pending signups, so Postgres never holds anything unverified.
Stack
Decisions
- 01Split the plan from what happened, and create the occurrences only when a period is first opened. A commitment is one row; an occurrence is one date, one actual amount, one paid flag. Nothing is written for months nobody looks at, and editing a commitment never rewrites history. The cost is that a period you never open has no record: fine for something checked weekly, wrong for an audit trail.
- 02No user row exists until the emailed code is verified. Signup puts the pending details and a SHA-256 hash of a six-digit code into Redis for fifteen minutes, and the account is created inside the verification request rather than before it. An abandoned signup expires on its own instead of leaving an unverified row for someone to collide with or claim.
- 03Made signup answer identically whether or not the address is already registered. A different message for a taken email turns the form into a membership oracle for any address someone cares to test. The existing account simply receives no mail.
- 04Put two independent brakes on login. django-axes locks a username and IP pair out for an hour after ten failures, and underneath it each failed attempt adds an exponential delay, capped at eight seconds. The lockout stops a sustained attack; the delay makes the cheap, fast part of one uneconomic long before it.
- 05Kept money as a fixed-point decimal to two places, from the column through to the input field, with the same floor, precision and digit limit enforced on both sides of the wire. A client that rounds differently from the server produces a figure that looks saved and is not.
- 06Wrote the three charts rather than installing one. A donut of SVG arcs, paired bars for expected against actual, and a month meter marked with today's pace. They read the same custom properties as the rest of the app, which makes a sixth theme a variable change instead of a chart config.
- 07Held the app behind a loader that retries its first request five times. The API sleeps when idle on its hosting tier, so the first visitor of the morning waits on a cold start, and a blank page would read as a broken site. The loader names the attempt it is on.
Outcome
Live at simplybudget.co.uk with open signup, built over about ten months. The split between a commitment and its occurrences was the load-bearing decision: Dashboard and Reports were both added near the end and needed no schema change, because each is only a different reading of the pair the tracker was already writing.