AI-augmented personal finance manager — single-user, self-hosted, pt-BR interface.
DeepSave imports your transactions from the banks via Pluggy (open-banking API), categorizes them with DeepSeek into a monthly spending tree, and learns your category/tag decisions over time — no spreadsheets, no manual categorization of every purchase.
- Backend: Rust (Axum 0.8, SQLx 0.9, tokio) + PostgreSQL 16
- Frontend: React 18 + Vite + TypeScript + Tailwind v4 + TanStack Query + ECharts
- AI: DeepSeek (
deepseek-v4-flashdefault; vision/pro model slots in.env.example) - Deploy: single multi-stage Docker image (backend + built SPA), docker-compose, GitHub Actions CI
The old document-upload import (statements, faturas, receipts) has been decommissioned in favor of Pluggy. Legacy document rows still exist in the database, but nothing imports through them anymore.
| Bank sync | Config-driven Pluggy accounts (Nubank, Caixa, C6…), incremental import, refund netting, installment series |
| AI categorization | Batch proposals reviewed one-by-one in a modal or applied in bulk; "nova: X" creates a category on the fly |
| Learning memory | Every category/tag decision is recorded in an append-only change log and fed back into the AI prompts |
| Charts | Category donut, 12-month trend, daily stacked bars, GitHub-style spending heatmap, top tags |
| Forecast | Future installments + recurring obligations projected 30–180 days ahead |
| Recurring rules | Alias-based matching that links rules to real items — analytics only, never double-counts |
| Tags | Normalized (lowercase, accent-stripped), with registry descriptions injected into prompts |
| Monthly digest | Per-month AI narrative (resumo, destaques, avisos) generated on demand |
| Single-user auth | One argon2 password + signed session cookie, rate-limited login |
| Route | Page | What it does |
|---|---|---|
/ |
Gráficos | Summary cards, category donut, 12-month trend, daily spend by category, calendar heatmap, top tags — all honoring the shared filters |
/forecast |
Previsão | Horizon selector (30–180 days), monthly recurring cost, expected spend, stacked forecast chart (parcelas vs recorrentes), upcoming breakdown table |
/lista |
Lista | Full transaction history (500-row cap) with search/date range/multi category/multi tag/bank/kind/installment filters, bulk edit, AI tagging, manual recurring-rule linking, refund chips |
/memory |
Memória | Tabs: Categorias (CRUD), Tags (usage counts, rename, merge, delete, descriptions), Histórico (change log), Diário (free-form notes) |
/recurring |
Recorrentes | Rules with alias/isolated-case name entries, derived tags (with conflict flag), recent occurrences, monthly cost, link-to-item flow |
/system |
Sistema | Pluggy integration (accounts, sync, forced re-sync of a period) + DB stats and items-by-status |
/login |
— | Single-password login (default dev password: deepsave) |
/categories, /tags and /pluggy redirect to their merged pages.
Builds backend + frontend into one image and starts it alongside Postgres:
cp .env.example .env # set SESSION_SECRET, APP_PASSWORD, DEEPSEEK_API_KEY…
docker compose up --build # full app at http://localhost:8080- The backend serves the built SPA, so one container, one port.
- Log in with the password from
APP_PASSWORD(default dev password:deepsave). docker compose up --buildrequiresSESSION_SECRETandAPP_PASSWORDin.env(the compose file fails loudly if they're missing).
For an edit-and-reload loop use docker compose watch:
it watches the sources and rebuilds/recreates the app container on every save
(BuildKit reuses the cached Rust/node dependency layers, so only the changed
stage recompiles).
docker compose watchAll settings are environment variables (see .env.example):
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
— (required) | Postgres connection string |
PORT |
8080 |
HTTP port |
SESSION_SECRET |
dev fallback | Cookie-signing key — must be ≥ 32 bytes (guarded at startup) |
COOKIE_SECURE |
false |
Set true once served over HTTPS (adds Secure to the session cookie) |
APP_PASSWORD / APP_PASSWORD_HASH |
deepsave |
Login password — plaintext (hashed at startup) or pre-computed argon2 hash; hash preferred in production |
STATIC_DIR |
./frontend/dist |
Built SPA served by the backend |
STORAGE_DIR |
./storage |
Legacy document storage (volume appdata in compose) |
COVERAGE_MONTHS |
12 |
Coverage window tracked per source |
DEEPSEEK_API_KEY |
— | DeepSeek API key |
DEEPSEEK_BASE_URL |
https://api.deepseek.com |
DeepSeek endpoint |
DEEPSEEK_MODEL |
deepseek-v4-flash |
Chat/categorization model |
DEEPSEEK_INPUT_PRICE_PER_M / DEEPSEEK_CACHE_HIT_PRICE_PER_M / DEEPSEEK_OUTPUT_PRICE_PER_M |
0.27 / 0.07 / 1.10 |
Token prices (USD per 1M) used for ai_calls cost accounting |
PLUGGY_API_KEY |
— | Pluggy auth (API-key mode — see below) |
PLUGGY_CLIENT_ID / PLUGGY_CLIENT_SECRET |
— | Pluggy auth fallback (client-credentials mode) |
PLUGGY_ACCOUNTS |
— | JSON array of accounts to import (see below) |
DAILY_PLUGGY_SYNC |
true |
Set false to disable the automatic daily Pluggy sync |
- Auth is a single password (argon2) with a signed
deepsave_sessioncookie (HttpOnly,SameSite=Lax,SecurewhenCOOKIE_SECURE=true). - Only
/api/health,/api/auth/login,/api/auth/logout,/api/auth/meand the login-page assets are unauthenticated; everything else under/apirequires a session. - Login is rate-limited in-app (20 attempts/min per peer); a reverse proxy
(see
deploy/) can add an IP-level limit on top.
Transactions are pulled straight from the banks — no files, no uploads. The UI lives
in Sistema (/system); the old /pluggy route redirects there.
-
Accounts are config-driven. Pluggy account ids are stable, so they're read once from the Pluggy dashboard and listed in
PLUGGY_ACCOUNTS:[{"id":"<uuid>","bank":"nubank","kind":"BANK","name":"Nubank - Conta"}, {"id":"<uuid>","bank":"nubank","kind":"CREDIT","name":"Nubank - Cartão"}]bankis one ofnubank|caixa|c6,kindisBANK|CREDIT(drives sign conventions). Accounts are seeded intopluggy_accountsat startup and re-seeded on every sync. -
Auth is API-key first.
PLUGGY_API_KEYis sent directly asX-API-KEYand never refreshed (ideal for single-user setups).PLUGGY_CLIENT_ID/PLUGGY_CLIENT_SECRETare the fallback (JWT/authflow, cached 90 min, auto-refreshed on 401). -
Sync (
POST /pluggy/sync) pulls/v2/transactions(cursor-paginated) per account and imports them asconfirmeditems (source = 'pluggy'), idempotent viaitems.external_id(partial unique index) — existing rows are never touched.- Incremental by default: only transactions newer than the account's last imported date (minus a 3-day overlap for late-posted charges).
- Forced full re-pull: pass
?from=&to=(YYYY-MM-DD) — the Pluggy UI has date inputs for this. - A daily automatic sync (last 7 days) runs ~45 s after startup and every 24 h
(
DAILY_PLUGGY_SYNC=falsedisables it).
-
Post-processing on every sync:
link_refundspairs each refund with the expense it reverses (exact |amount| + merchant tokens first, partial second) — linked refunds net their expense to zero in every chart.assign_installment_seriesgroups installments intopurchase_seriesso the forecast can project future parcels.- The MCC → category rule deterministically categorizes uncategorized card items (5411 → Supermercado, 5812 → Restaurantes, 5542 → Transporte, 5912 → Saúde, 5817/5818/5968 → Assinaturas, retail → Outros…).
-
Sign conventions: bank DEBIT → negative expense, CREDIT → positive income; card charges (positive DEBIT) → negative expense with
installment/installment_countfromcreditCardMetadata; card-side payments/refunds are skipped; bank-side "Credit card payment" becomesinternal(already on the card side).
- Proposals (
services/ai_tags.rs): select items in Lista → "Taggear com IA" → a background worker sends a compact payload (existing categories/tags, item rows, and per merchant the last tagged examples) to DeepSeek. Batches carry akind:tags|categorize|full(category + tags in one call). - Review: suggestions are reviewed one-by-one in a modal (per-field apply checkboxes, editable category dropdown incl. "sem categoria" — clears the category — and "nova: X" — creates a new one) or in bulk from the amber banner (Aplicar/Ignorar todas, "Só itens com sugestão" filter).
- Prompts always receive: the category list, tag descriptions (from the
tagsregistry), the last 5 change-log entries per merchant, tagged examples, and Pluggy metadata (pc/mcc/op/pay). Tags that are also category names are never suggested. - Learning: every category/tag decision you make (item edit, bulk edit, AI apply,
memory apply) is appended to the change log (
/memory→ Histórico). The AI learns only from this log — the oldmerchant_memorytable was decommissioned (its rows becamesource='legacy'entries). - Monthly digest: on Gráficos, a full-month view offers generating a saved AI narrative
(
resumo,destaques,avisos) via/api/dashboard/digest. - Every AI call is recorded in the
ai_callstable (model, tokens, cost, purpose).
- Amounts are integer cents; negative = expense, positive = income.
- Refund netting: a refund linked to its expense is treated as a negative expense bucketed by the charge's month/category/merchant — nothing inflates, nothing double-counts.
card_payment(credit-card bill payments) andinternal(transfers between your own accounts) are excluded from spend/income.- Recurring rules are analytics-only — they never auto-create items (statement items
are the single source of truth). Matching is alias-based (normalized exact equality
against
recurring_aliases, tolerating a trailing amount); manual links (POST /items/{id}/link-recurring) win over automation;next_due_onis never in the past. - Installments live in
purchase_series; withinstallments=first_onlythe first parcel shows the whole price (amount_cents × installment_count). - Categories are intrinsic to a merchant; tags are situational. Tag rename/merge/delete cascade to items, recurring rules and the tags registry.
- Migrations are embedded at compile time (
sqlx::migrate!) and auto-applied at startup;backend/migrations/must exist when building (including in Docker). - Legacy documents: tables/rows remain from the decommissioned upload pipeline, but nothing imports through them anymore.
# 1. start postgres
docker compose up -d postgres
# 2. backend (runs migrations on startup)
cp .env.example .env # adjust values
cd backend && cargo run # http://localhost:8080/api/health
# 3. frontend (separate terminal)
cd frontend && npm install && npm run dev # http://localhost:5173The Vite dev server proxies /api to the backend on port 8080. The backend also serves
the built SPA from STATIC_DIR with an index.html fallback — keep /api/* routes
above the fallback.
cd backend && cargo test # backend tests (needs a Postgres reachable via DATABASE_URL)
cd frontend && npm run build # type-check + build frontend- Integration tests use
#[sqlx::test]+wiremock(fake Pluggy/DeepSeek APIs); the Pluggy mapping/dedupe matcher has unit tests inservices/pluggy.rs, and the full import pipeline is exercised intests/pluggy.rs. - CI (GitHub Actions) runs
cargo check+cargo testagainst a Postgres service and a production frontend build on every push/PR.
cargo install sqlx-cli --no-default-features --features rustls,postgres
cd backend && sqlx migrate runPopulates the database with fake categories, accounts, a 3-month spending tree, transfers, income and recurring rules (useful while testing the UI):
./scripts/seed.shRuns from the host via docker exec into the Postgres container (override the
container name with DEEPSEED_PG_CONTAINER if needed). Re-running it resets and
re-seeds the data tables.
Postgres runs in the deepsave-postgres container (user/db deepsave); legacy uploaded
files live in the appdata volume (/app/storage), not in the database.
./scripts/backup # writes ./backups/deepsave-<timestamp>.dump (+ -appdata.tar.gz)
./scripts/restore # restores the most recent backup; or pass a timestamp:
./scripts/restore 20250101-120000Both accept DEEPSAVE_BACKUP_DIR, DEEPSAVE_PG_CONTAINER, DEEPSAVE_PG_USER,
DEEPSAVE_PG_DB, DEEPSAVE_APPDATA_VOLUME env overrides (see headers in the
scripts). Restore overwrites the current database and restarts the app.
The repo ships a complete remote-access setup in deploy/:
a WireGuard tunnel from your home machine (where DeepSave runs, behind NAT —
no port-forwarding needed) to an always-up VPS that reverse-proxies
ds.cuchi.me over HTTPS with an offline fallback page.
- VPS: WireGuard server (
wg0), nginx (security headers, per-IP rate limiting, 20 MB body cap, offline fallback) — reference configs indeploy/vps/. - Home: WireGuard client (
deepsave.conf),PersistentKeepalivekeeps the NAT mapping fresh — survives home IP changes. - Read the full walkthrough (DNS, key generation, firewalld, troubleshooting)
in
deploy/README.md.
Base: /api (JSON, snake_case field names; errors are { "error": ... }). Everything
except the auth/health routes requires the session cookie.
| Area | Endpoints |
|---|---|
| Auth | POST /auth/login, POST /auth/logout, GET /auth/me, GET /health |
| Items | GET/POST /items, PATCH /items/bulk, GET /items/summary, GET/PATCH/DELETE /items/{id}, POST /items/{id}/link-recurring, POST /items/link-recurring, GET /banks |
| Categories / Tags | GET/POST /categories, DELETE /categories/{id}, GET /tags, GET /tags/usage, GET /tags/registry, PATCH /tags/{tag}, PATCH /tags/rename, POST /tags/merge, DELETE /tags/{tag} |
| Memory / History | GET /change-log, GET/POST /diary, PATCH/DELETE /diary/{id} |
| AI tags | POST /ai-tags/batches, GET /ai-tags/batches, GET /ai-tags/suggestions, POST /ai-tags/suggestions/{id}/apply|dismiss, POST /ai-tags/suggestions/apply-all|dismiss-all |
| Recurring | GET/POST /recurring, PATCH/DELETE /recurring/{id}, GET /recurring/{id}/occurrences, GET /recurring/merchants, GET /recurring/merchant-profile, GET /recurring/monthly-cost |
| Dashboard | GET /dashboard, GET /dashboard/trend, GET /dashboard/daily, GET /dashboard/tags, GET /dashboard/forecast, GET /dashboard/upcoming, GET/POST/DELETE /dashboard/digest |
| System / Pluggy | GET /system, GET /pluggy/status, GET /pluggy/accounts, POST /pluggy/sync |
Shared filters on items/dashboard routes: search, date_from/date_to,
category_ids (multi, OR), tags (multi, OR, __none sentinel), bank, kind,
installments (all/first_only/only), limit (list, default cap 500).
PLAN.md— remaining work (categories tree UI, usage endpoint, …)PLAN-DONE.md— completed milestones M0–M21AGENTS.md— engineering conventions & gotchas (sqlx 0.9, auth, Pluggy…)