Personal finance, made clear. Live at https://balanced.money.
Most people trying to get a grip on their money build a spreadsheet, then quietly abandon it around month three. Balanced Money is that spreadsheet with the boring parts already done: the structure, the formulas, and the charts. You still bring the numbers — what you don't have to do is decide how to organise them.
It never connects to your bank. There is no Open Banking link and no third party with read access to your accounts: figures get in via a CSV you export yourself, or by typing.
The repository is named halcyon; the product it ships is Balanced Money. Same thing — the repo name predates the brand.
| Transactions | Export a CSV from your bank and drop it in. Rows you've already imported are detected and skipped, so overlapping exports are safe, and every import is a batch you can undo in one click. Tag each row with a category. |
| Budget | What you meant to spend, month by month, split into Fixed, Variable and Discretionary. Once transactions are categorised the actual column fills itself in beside your plan. An optional transfers panel keeps money moved between your own accounts out of income and expenses. |
| Balance | What you own and what you owe, sorted by how soon it matters — current, medium-term, long-term, property. The bottom line is your net worth. |
| Dashboard | Read-only, and derived: cash flow, savings rate, spending by category and net worth over time, all drawn from the months filled in elsewhere. |
| Plan | The long view. Project income, spending, property, mortgages and pensions decades ahead to see whether the money lasts. Independent of the month-to-month figures. |
| Settings | Currency and number format, light/dark/system, which charts appear, categories and accounts — plus a full JSON export of your data and a permanent delete. |
There is a guide at /about explaining the
monthly rhythm the app is built around, reachable without an account.
Built as a learning project, and documented like one. Start with the Playbook for how it was approached, or the docs directory for architecture and design.
- Development: http://localhost:3210/ (
pnpm devormake up). - Production: https://balanced.money (also served on the Vercel default domain, https://halcyon-silk.vercel.app).
- Hosting: Vercel runs the Next.js app (App Router server components + route handlers + middleware). Supabase provides managed Postgres and Auth. See ADR-001 for the rationale.
- Pipeline: pushes to
mastertrigger GitHub Actions (.github/workflows/ci.yml) which runsbiome ci,tsc --noEmit, Jest, and Playwright on Node 22 + pnpm 11. Vercel watchesmasterindependently and ships the build to production once its own build passes. - Database migrations: applied by GitHub Actions, not by the host. The
migrate-prodjob in.github/workflows/ci.ymlrunsprisma migrate deployagainst the productionDIRECT_URL(unpooled Supabase connection, port 5432, supplied via thePROD_DIRECT_URLrepo secret) — but only on push tomasterand only after lint/test + e2e pass. Vercel is configured to wait for this workflow before deploying, so the new code never goes live against an un-migrated schema. Migrations are forward-only; to undo, write a corrective migration. Manual fallback:pnpm exec prisma migrate deploywithDIRECT_URLset. - Rollback: one click in the Vercel dashboard (it keeps every previous build immutable). Pair with a corrective DB migration if a release introduced a schema change.
- Secrets: managed in the Vercel project settings, not committed. The repo's
.env.examplelists every variable the app reads. See ADR-004.
Install dependencies with pnpm, then run the app in Docker for local development (this is what points it at the local database — see below):
pnpm install # install dependencies
make build # first run: build images, start Docker, migrate + seed (http://localhost:3210)
# thereafter: make # start the app and local Postgres (attached)makefor the app + the database (runs inside Docker, against local Postgres):make/make build(first-run setup) /make up/make down/make rebuild,make migrate-create name=<verb_table>(author a new migration),make migrate-deploy(apply pending migrations),make db-reset,make db-seed,make db-shell.pnpmfor stateless checks (faster, and what CI runs):pnpm typecheck,pnpm check(lint+format),pnpm test,pnpm build,pnpm verify.- Migrations: always
make migrate-create/make migrate-deploy, never hostpnpm prisma …— see the gotcha below.
Two databases exist: local Postgres (the Docker db service) and production Supabase. Production DB URLs live only in Vercel env vars and CI secrets — no local file holds them. Locally it shakes out as:
| Command | Reads | Hits |
|---|---|---|
App in Docker — make up |
compose.yaml env (local) |
local |
App on host — pnpm dev (Next.js) |
.env.development → .env |
local |
Prisma CLI on host — pnpm prisma … |
nothing (prisma.config.ts loads no env file) |
fails loudly |
Two local env files, two roles: .env.development (committed) holds non-secret defaults — the local DB URL; the gitignored .env holds only the Supabase auth values (URL + keys, no DB URLs) — it's also the only file Docker Compose can interpolate ${...} from, which is why the secrets live there and not in a .env.local.
Prisma migrations always run inside the container — make migrate-create / make migrate-deploy — where compose.yaml pins DATABASE_URL/DIRECT_URL to the local db service. A stray host pnpm prisma migrate fails loudly (no connection string) instead of silently touching anything.
I have done my best, with the support of AI to put a comprehensive set of documents in place to help me and others understand the project and its architecture.
- Tech Stack
- Playbook
- Data Models
- Architecture Decision Records (ADRs)
- Security Architecture
- Row Level Security — the two doors into the data, and the hand-written step Prisma can't generate
- Auth Flow (sequence diagrams)
- User Personas
- User Journeys
- Stakeholder Mapping
- Success Metrics
- Data Privacy Statement
- Design System (DESIGN.md)
- Accessibility Standards
The unit tests run against the code in the src/ directory, rather than the container code, which improved the speed and reliability of the tests. In other projects i have worked on, running tests against the container code was a common source of frustration.
To run the unit tests, use the following command: pnpm test, or one of the helpers listed below:
make testmake test-watchmake test-coverage
Server actions and DB queries (imports, categorisation, merge, provisioning,
ledger queries) are tested against a real halcyon_test database, with only
the Supabase auth boundary mocked. Files use the *.int.test.ts suffix and run
in a node-env Jest project, separate from the unit run.
pnpm test:intIt needs a Postgres reachable at localhost:5432 (the db container from
make up is fine — the test database halcyon_test lives alongside the dev
halcyon DB). The command pins DATABASE_URL at halcyon_test and a guard
refuses to run against anything else; globalSetup applies migrations.
Run the tests locally:
-
Install browser system deps once:
sudo npx playwright install-deps. -
Install the project deps:
pnpm install. -
Install the browsers if missing:
pnpm exec playwright install chromium. -
Run the tests:
make test-e2e # all specs make test-e2e name="transfers journey" # only specs/tests matching the grep
make test-e2ebrings the local Postgres up and migrateshalcyon_testfirst (see below), then runs Playwright. To run Playwright directly instead:pnpm exec playwright test(or--grep "<pattern>", orpnpm test:e2e:uifor the UI runner). Avoidpnpm test:e2e -- --grep: the bare--reaches Playwright, which reads--grepas a positional file filter ("No tests found").
Playwright spins up two webservers automatically:
- a mock Supabase Auth server on
localhost:54321(seee2e/_mock/supabase.mjs) - a Next.js dev server on
localhost:3100(the deliberately-different port lets the test server coexist with a developer's ownpnpm devon:3210)
Auth is always mocked (no real Supabase project is touched). DB-touching
journeys (transactions, transfers) do hit a real halcyon_test Postgres at
localhost:5432, connecting as test:test to match the CI Postgres service.
The db container provisions halcyon_test and the test role automatically
on first volume init (see docker/postgres-init.sql),
and make test-e2e applies migrations to it — so a cold make test-e2e works
end to end. Coverage and approach are documented in docs/features/auth.md.
To seed the local development database, use the following command: pnpm db:seed, or one of the helpers listed below to seed and reset the database in the container.
make db-seedmake db-reset
Landing-page screenshots live in public/marketing/ (dashboard.png, budget.png, balance.png, transactions.png).
Re-capture them whenever the dashboard, budget, balance or transactions UI changes — they are the first thing a prospect sees, and a stale set advertises a product that no longer exists.
The capture runs entirely locally against the e2e stack: the mock auth server, the halcyon_test database, and a dev server. Nothing touches cloud Supabase or production data, and the script signs itself in and seeds its own twelve months of demo data, so there is no session to set up by hand.
make e2e-db # local Postgres, migrated
node e2e/_mock/supabase.mjs & # mock auth on :54321
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 \
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_test_anon_key_for_e2e \
SUPABASE_SECRET_KEY=sb_secret_test_key_for_e2e \
DATABASE_URL=postgresql://test:test@localhost:5432/halcyon_test \
DIRECT_URL=postgresql://test:test@localhost:5432/halcyon_test \
pnpm next dev -p 3100 &
node scripts/capture-shots.mjsThe script refuses to run against anything but a local halcyon* database. Set CAPTURE_SCHEMES=light,dark to also produce -dark.png variants for design review — the landing page ships the light set only, since it is seen signed-out and serving both would mean downloading both.
Until the files exist the landing page renders labelled placeholders via MarketingShot, so the page ships and works correctly without them.
Contributions are always welcome! Please open a pull request or issue to discuss any changes you would like to make.
Issues and pull requests are welcome on GitHub.



