Cross-platform personal finance tracker for US and Mexico accounts, investments, crypto, taxes, and USD/MXN reporting.
See the Project Overview or the Deployment Guide for the full setup notes.
SoFi · Chase · American Express · Capital One · Bilt · US Bank · Fidelity · Fidelity NetBenefits · Vanguard 401k · HealthEquity HSA · Robinhood
Coinbase · Bitso
Nu Bank Mexico · Banamex · Cetesdirecto
Track money you lend to friends and family. Enable the module from Management → Modules to reveal a Lending tab. Each loan supports no/simple/amortized/interest-only/compound interest at a per-year or per-month rate, auto-suggest reconciliation against your real bank transactions (disbursement out, repayments in), generated payment schedules with due-date reminders, and interest-income reporting with CSV exports plus a printable promissory-note agreement.
| Layer | Technology |
|---|---|
| Backend | Rust + axum |
| Database | PostgreSQL 17 |
| Cache | Redis 7 |
| Frontend | Flutter — web (served by nginx in Docker) + native Android APK |
| Financial Data | Plaid, Coinbase OAuth, Bitso API, CSV/PDF import |
| Exchange Rates | ExchangeRate-API with Redis caching |
| Deployment | Docker Compose locally; static hosting + API container for production |
- Docker and Docker Compose
- Optional integration credentials in
.envfor Plaid, Coinbase, Bitso, and ExchangeRate-API
git clone https://github.com/nickvander/patrimonio.git
cd patrimonio
cp .env.example .env
docker compose up --build -dOpen the app at http://127.0.0.1:3000.
docker compose only reads .env from its current directory, so a
freshly-created worktree comes up without any of the Plaid /
encryption secrets and every Plaid sync fails with
"Encryption key missing". The wrapper below symlinks the repo-root
.env first, then runs docker compose up -d --build:
bash scripts/dev-up.sh # build + start everything
bash scripts/dev-up.sh frontend api # rebuild just these servicesIf you'd rather call docker compose directly, run
bash scripts/setup-env.sh once after creating the worktree to
plant the symlink.
Service URLs:
- Frontend:
http://127.0.0.1:3000 - API:
http://127.0.0.1:8080 - Health check:
curl http://127.0.0.1:8080/api/health - Postgres host port:
5433 - Redis host port:
6380
There is no default username or password. On first launch the app shows a one-time bootstrap screen and asks you to create the owner account directly. This is a deliberate choice — shipped defaults on self-hosted financial software get scanned for and exploited.
- Open
http://127.0.0.1:3000afterdocker compose up. - Pick a username and a password of at least 12 characters. An email is optional and used only for future password reset.
- The bootstrap endpoint refuses to run a second time, so the account you create here is the only one until multi-user support lands.
If you forget the password, drop the users row in Postgres and the
bootstrap screen reappears:
docker exec -it patrimonio-postgres-1 \
psql -U patrimonio -d patrimonio -c "DELETE FROM users;"In any deployment served over HTTPS, set COOKIE_SECURE=true in
.env so the session cookie is only sent over TLS. The cookie is also
marked Secure automatically when FRONTEND_BASE_URL starts with
https://.
After bootstrap, open the Security screen (shield icon in the top-right of the dashboard) to layer extra factors on top of the password.
TOTP (recommended for every install)
- Security → "Add an authenticator app".
- Scan the QR with Authy / Google Authenticator / 1Password, OR paste the displayed base32 secret if your authenticator doesn't scan.
- Enter the 6-digit code from your authenticator to confirm. The confirm step uses a no-replay-advance verifier — you can log in immediately afterward with the same code, even if you're still inside the same 30 s TOTP step.
- On subsequent logins,
/loginreturnsrequires_totp: trueafter the password step and the UI prompts for the code viaPOST /api/auth/totp/verify.
The TOTP secret is encrypted at rest with ENCRYPTION_KEY (set
this before enabling TOTP — see Plaid section).
Recovery codes
Bootstrap mints 10 one-time recovery codes and shows them ONCE on the response. Save them somewhere persistent (password manager, printed copy). Each code is a 12-character base32 string.
- The Security screen shows the count of unused codes. When it drops below 3 a warning tile appears with a "Regenerate" CTA.
POST /api/auth/recoverconsumes a code + resets the password. Use this if you lose your authenticator AND can't reach the database to clear theusersrow.- "Regenerate" mints a fresh batch of 10 codes and invalidates the old ones in one stroke — useful if a code might've leaked.
Passkeys (FIDO2 / WebAuthn)
Hardware keys (YubiKey, Apple Touch ID, Windows Hello,
cross-device phone-to-desktop QR) all work via
webauthn-rs.
- Security → "Register a passkey".
- Follow the browser prompt — pick the authenticator type your device offers.
- On the login screen, click "Sign in with a passkey" instead of typing a password. The discoverable-credentials flow means you don't need to type a username first.
Multiple passkeys can be registered per account — keep a hardware key in a safe as backup. Account recovery still falls back to password + TOTP, so removing all passkeys doesn't lock you out.
Hardening notes
- Failed login attempts get a 50–150 ms random jitter before responding (closes a sub-threshold brute-force probe at HTTP throughput) and the 429 path applies exponential backoff (1 → 2 → 4 → 8 → 16 → 30 s capped) once you cross 5 failures per minute.
X-Forwarded-Forfrom untrusted peers is stripped before reaching the rate limiter — setTRUSTED_PROXY_CIDRSto your reverse-proxy's IP in production.- CSRF defence: every mutating request (POST/PUT/PATCH/DELETE)
must include an
X-Requested-Withheader. The frontend sends this automatically; curl / scripted clients need to add it.
NODE_PATH=/path/to/node_modules ./scripts/smoke.cjsThe smoke test verifies API health and renders the Flutter app in a browser with Playwright. If Playwright is not installed locally, install it in your Node environment or run with SKIP_BROWSER=1 for API-only validation.
# Backend
cd backend
cargo run
# Frontend
cd frontend
flutter pub get
flutter run -d chromeThe same Flutter frontend builds a native Android app that connects to your self-hosted backend over HTTPS:
cd frontend
flutter build apk --release
# → build/app/outputs/flutter-apk/app-release.apkOn first launch the app asks for your backend URL (e.g.
https://patrimonio.nickvda.com) and remembers it; you can also bake one in with
--dart-define=API_BASE_URL=…. The app is HTTPS-only, so the backend must be
served over TLS. If your deployment sits behind Cloudflare Access, the setup
screen's Advanced section takes a CF Access service token — any other HTTPS
front door needs only the URL. Release signing, install steps, and the
Cloudflare walkthrough are in the
Deployment guide.
All data routes require a session cookie. Public endpoints are health,
version, setup status, and /api/auth/{status,login,bootstrap}.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/health |
public | Health check plus DB status |
| GET | /api/version |
public | App version |
| GET | /api/setup/status |
public | Integration config readiness |
| GET | /api/auth/status |
public | needs_bootstrap, authenticated, current user |
| POST | /api/auth/bootstrap |
public, one-shot | Create the owner account on first run |
| POST | /api/auth/login |
public | Sign in, sets session cookie |
| POST | /api/auth/logout |
session | Revoke the current session |
| GET | /api/auth/me |
session | Current user profile |
| POST | /api/auth/change-password |
session | Rotate password, revokes other sessions |
| GET | /api/auth/coinbase/* |
session | Coinbase OAuth link flow |
| GET | /api/accounts |
session | List all accounts |
| GET | /api/accounts/summary |
session | Net worth summary |
| GET | /api/institutions |
session | List linked institutions |
| POST | /api/institutions |
session | Add an institution |
| GET | /api/fx/latest/:base/:target |
session | Latest exchange rate |
| GET | /api/fx/history/:base/:target |
session | Exchange rate history |
| GET | /api/dashboard/overview |
session | Dashboard aggregations |
| POST | /api/plaid/* |
session | Plaid link and sync routes |
| POST | /api/imports/* |
session | CSV/PDF import routes |
| GET | /api/tax/* |
session | Tax estimates and exports |
patrimonio/
├── backend/ # Rust API (axum)
│ ├── src/
│ │ ├── main.rs # Entry point, router setup
│ │ ├── config.rs # Environment config
│ │ ├── api/ # HTTP handlers
│ │ ├── models/ # Database structs
│ │ ├── services/ # Plaid, crypto, FX, tax, imports
│ │ └── db/ # Database utilities
│ ├── migrations/ # SQL migrations
│ ├── Cargo.toml
│ └── Dockerfile
├── frontend/ # Flutter app and web Docker image
├── docs/ # MkDocs documentation
├── scripts/ # Local validation scripts
├── work/ # Project specs and tracking
├── docker-compose.yml
├── .env.example
└── README.md
- Foundation: backend scaffold, database, Docker setup
- Plaid integration: US account linking, balances, transactions, holdings
- Dashboard: overview, charts, portfolio, FX, transaction search
- Mexico imports: Nu, Banamex, Cetesdirecto CSV/PDF parsing
- Crypto: Coinbase OAuth and Bitso API support
- Tax planning: US/Mexico estimates and taxable exports
- Personal lending (opt-in): loans, reconciliation, schedules, interest-income reporting
- Local launch hardening: Dockerized frontend and smoke tests
- Production deployment: hosted frontend/API, backups, monitoring, real credentials
| Item | Monthly Cost |
|---|---|
| Plaid pay-as-you-go | ~$0-5 during light personal use |
| ExchangeRate-API | Free tier available |
| Local Docker hosting | Free |
Private - personal use.