Skip to content
← All work

Savoney

Personal finance, typed end to end

Language

TypeScript

Frontend

React 19 · TanStack Query · Recharts

Backend

Express 5 · Mongoose 9

Data

MongoDB 8

Shared

Zod

RepositoryGitHub ↗live site not linked yet

01The problem

Expense trackers either demand a discipline nobody keeps, or collect everything and tell you nothing.

If an app asks for my attention every day, what does it owe me back?

02The solution

A TypeScript monorepo where one set of Zod schemas drives the client, the server and the API docs, so none of them can drift.

Built end to end: auth, transactions, budgets, goals and analytics.

03Architecture

A React 19 client and an Express 5 API over MongoDB 8, sharing a single package of Zod schemas and types.

formsvalidatesgeneratesRESTrotatetoken familyaggregation@savoney/sharedZod · inferred typesReact 19 clientVite · TanStack QueryExpress 5 API6 resource groups/api/authrefresh cookie scoped here/api/docsOpenAPI, generatedMongoDB 8Mongoose 9 · minor units

One package of Zod schemas that the client, the server and the OpenAPI document all derive from, so none of the three can drift. Connections: @savoney/shared to React 19 client (forms); @savoney/shared to Express 5 API (validates); @savoney/shared to /api/docs (generates); React 19 client to Express 5 API (REST); React 19 client to /api/auth (rotate); /api/auth to MongoDB 8 (token family); Express 5 API to MongoDB 8 (aggregation).

scroll the diagram sideways →

BrowserServiceGate / cacheDataExternaldashed = on a timer

04Technical depth

  • Refresh tokens are single-use; presenting one twice revokes the entire session family.
  • Access tokens live 15 minutes, in memory, never localStorage.
  • The refresh cookie is SameSite=Strict and scoped to /api/auth, which makes the API CSRF-safe by design.
  • Passwords use Argon2id; money is stored as integer minor units, never floats.
I can't stop a token being stolen, but I can make using it announce itself.