Overview
Fundlo.dev is a personal money tracker that I designed, built and run on my own. The public site lives at fundlo.dev. The app itself runs on app.fundlo.dev, behind registration and a subscription paywall.
The idea is simple: most budgets die in a spreadsheet by February. Fundlo is built so that writing an expense down takes less effort than forgetting it, and so that the people you share costs with can see the same numbers without seeing your private finances.
This is not a weekend demo. It is a production SaaS with authentication, billing, background jobs, real-time updates, transactional email, error tracking, a CI/CD pipeline and its own server.
What It Does
Each user sees only their own data. They can:
- Record expenses and income in any currency, split one receipt across categories, and tag it
- Use templates and recurring rules so regular payments are one tap or no tap at all
- Set monthly budgets per category and see how close the month is to each limit
- Save toward savings funds with a target date and a “will I make it at this pace” forecast
- Track accounts and net worth, move money between accounts, and keep tabs on money owed to them
- Import bank statements or sheets from other apps (CSV or Excel), and undo an import
- Scan a receipt or invoice and have the amount, date, shop and category filled in
- Share expenses in groups with family, flatmates or friends, split them, settle up and chat
- Review it all in analytics and global search
One Contract for API and Web
The codebase is a Yarn workspaces monorepo: the API, the web app and the marketing site, plus shared packages for the contract, the design system, translations, email templates and PDFs.
The load-bearing package is packages/shared. Every request body, query string and response is a
Zod schema defined there once. The API validates incoming requests with it, the web app
builds its forms against it and parses every response through it, and the
OpenAPI documentation is generated from the same schemas.
Why this matters:
A field added on one side cannot be forgotten on the other - the build breaks first. The API docs can never drift from the code, because they are the code. The same package also holds the domain logic both sides must agree on: money math, dates and recurrence, budgets, savings funds and analytics. There is exactly one answer to “how much is left in this budget”.
Money Done Right
Amounts are stored as integers in minor units, never floats. A transaction keeps its original amount and currency, plus the amount converted to the user’s base currency and the exchange rate and date that were used.
When no rate is available yet, the transaction is saved anyway and a background job fills in the conversion later.
Why this matters:
Floating-point rounding errors are invisible until a total is off by a cent. Storing the rate with every conversion means history never changes when rates move, and the user is never blocked from logging an expense because an external rates API is slow.
Groups, Settle-Up and Chat
Every record has one of two owners: personal (only the user sees it) or group (every current member sees it). A single ownership helper turns that into the database filter, so the same transaction, category and tag logic serves both spaces. Group routes re-check membership and role on every request, so a viewer can never write and a removed member loses access on their very next call. Tests prove that neither space ever returns the other’s rows.
A group expense records who paid and can be split equally, by amounts or by percentages, with shares that always add up to the total exactly. Balances and the fewest-transfers settle-up view are pure functions in the shared package.
Each group has an “Everyone” room plus direct rooms between members:
- Message bodies are encrypted at rest with versioned keys, so keys can rotate without re-encrypting history
- New messages, edits, reactions, typing and read positions go over a WebSocket, only to current room members
- A transaction shared into a room is a live card, read fresh for each person, so it never shows stale numbers

Subscriptions and Paywall
Billing runs on Stripe, using Stripe Managed Payments so Stripe acts as the merchant of record and handles VAT for buyers across the EU. Plans are Free, Pro, Max and Family, plus a one-time lifetime license and a hidden plan that only an admin can grant.
Some of the rules behind it:
- Reverse trial - every new account gets 14 days of the top plan with no card, then drops to Free unless the user upgrades
- Limits never delete or lock data - they only block creating new things, which makes downgrades and onboarding safe by design
- Our database is the source of truth for plans and limits, Stripe for payment state - synced through webhooks and checked by a daily reconcile job
- A Free user can be invited to a group but joins as a viewer, so one paid account cannot unlock a whole group
Why this matters:
Billing is where most side projects stay half-finished. Getting trials, upgrades, downgrades, failed payments, refunds and EU consumer rules right is what turns an app into a business.
Authentication and Security
- Email and password with a 6-digit verification code, passwordless sign-in by emailed code, and Google sign-in with account linking
- Two-factor authentication through an authenticator app
- A short-lived access token held in memory only, plus a rotating refresh token in an httpOnly cookie, stored hashed. Presenting an already-rotated token revokes the whole session family as a reuse signal
- Rate limiting, security headers, and a self-hosted error tracker that never receives emails, request bodies, cookies or headers
Documents in the User’s Own Cloud
Receipts and documents never sit on Fundlo’s servers. Each user connects their own Google Drive, Dropbox or OneDrive. With Google Drive the app only requests access to files it created or the user picked.
Uploads are checked by their actual bytes, macro-enabled Office files and PDFs that run code are refused, and images are re-encoded. Files are renamed after the transaction and filed into year and month folders. Previews stream through the API, with Office files and CSV converted to PDF on the way.
Why this matters:
Storing financial documents is a liability. Keeping them in storage the user already owns and controls is better for privacy, cheaper to run, and easier to explain in a privacy policy.
Built for Every Language
Every string in the app, the API and the emails goes through translations. The API never returns English prose - only translation keys the client turns into text, and validation errors map straight onto form fields.
All language files must stay structurally identical, and admins can override any string at runtime from the admin panel, so a copy fix needs no deploy. The marketing site is prerendered to static HTML for every language, with a sitemap that links the language versions together.
Notifications and Background Jobs
Notifications are stored per user and pushed live over a single WebSocket, with deduplication so “Ana added 3 expenses” updates one row instead of stacking up. The same socket tells open pages when group data changes, so every member sees the same state without reloading. Installed as a PWA, the app also sends push notifications.
A small in-process scheduler runs the recurring work: creating due recurring transactions, converting pending exchange rates, reminding users of upcoming payments and subscription charges, nudging group members who owe money, and retrying failed emails with backoff so a flaky mail server never loses a sign-in code.
Admin
Admins manage users, plans, promo codes, referrals, subscriptions and sales, the default categories and tags, feedback, app settings and translations. A system page shows every scheduled job’s last run and result and lets an admin trigger one by hand.
Users can read the changelog and send bugs, ideas and screenshots from inside the app, and are notified as their feedback moves through the pipeline.
Deployment and Infrastructure
The whole product runs on a single EU-hosted VPS managed with Coolify:
- GitHub Actions builds Docker images only for the apps a change touched, pushes them to GitHub’s container registry, and tells Coolify to deploy - the server never builds anything
- Traefik (through Coolify) handles HTTPS for every subdomain: the site, the app, the API with its WebSocket and docs, and the error tracker
- PostgreSQL runs on the same server with automated backups to Cloudflare R2, and database migrations run automatically when a new API version starts
- Cloudflare handles DNS and inbound email forwarding, and a European transactional email provider handles outgoing mail
- CI on every pull request runs formatting, linting, type checks, the build and the test suite, with integration tests split across parallel jobs, each against its own real database
The planned running cost is around 6 euros a month, domain included.
Tech Stack
| Category | Technology |
|---|---|
| Web app | React 19, Vite, TanStack Router, Query and Table |
| UI | shadcn/ui, Tailwind CSS v4 |
| Marketing site | TanStack Start, prerendered static HTML |
| API | Node.js, Express 5, WebSockets |
| Database | PostgreSQL 17, Prisma 7 |
| Contract | Zod schemas shared by API and web, generated OpenAPI |
| Payments | Stripe (Managed Payments, subscriptions, webhooks) |
| Email and PDF | React Email, PDFs rendered in-process with WebAssembly |
| Testing | Vitest, integration tests against real PostgreSQL |
| Infrastructure | Docker, GitHub Actions, Coolify, Cloudflare |
| Monitoring | GlitchTip through the Sentry SDKs |
Key Takeaways
- One shared, typed contract is the single best investment in a full-stack app
- Money needs its own rules - integers, stored rates, and no blocking on external services
- Billing is a product feature, not an afterthought - trials, limits and downgrades deserve as much design as the UI
- Privacy can be an architecture decision: keep users’ documents in their own storage
- A solo developer can ship and operate a real SaaS with a disciplined monorepo, good tests and a boring, cheap deployment pipeline