Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

Budget Tracker

A personal budget tracker split into independently deployed services, plus a daily one-shot job that imports bank transactions from email. Everything is wired together by docker-compose.yml and reverse-proxied by nginx.

Services

  • backend-service (FastAPI, port 8502) — owns the SQLite database (data/budget.db, tables budget_set, budget_tracker and email_transactions). Exposes REST routes under /summary, /budget, /transactions, /query, /classify and /email-transactions, and also mounts an MCP server at /mcp (via fastapi-mcp) so selected routes can be called as tools. /email-transactions is deliberately not one of them.
  • agent-service (FastAPI + LangGraph, port 8000) — "Penny", an OpenRouter-backed LangChain agent that consumes backend-service's tools over MCP. Conversation state is checkpointed to Postgres. Data-mutating tools (adding/updating/deleting transactions or budgets) require human-in-the-loop confirmation; read-only tools do not.
  • frontend-service — the static app.html UI, served by nginx. Tabs: Summary, Spend, Budget, Bank (email-sourced, read-only) and Penny.
  • email-ingest-service — a one-shot job, not a server. It reads HDFC bank alert emails from Gmail, has an LLM extract each transaction, and POSTs them to backend-service. Host cron runs it daily. See Email-sourced bank transactions.
  • nginx — reverse proxy that routes /chat/* → agent-service, /summary|budget|transactions|query|classify|mcp|docs|redoc|openapi.json → backend-service, and everything else → frontend-service. /email-transactions also goes to backend-service but is GET-only: anything else gets a 403, because only email-ingest writes there, directly over the Docker network. See nginx/nginx.conf.

Running locally (backend-service + agent-service)

All services (and the db_backup/ scripts) share a single root .venv (a conda env) — there is no per-service virtualenv.

conda activate /Users/himanshusingh/Developer/budget-tracker/budget_tracker_api/.venv

backend-service

cd backend-service
pip install -r requirements.txt
python app/main.py

Runs at http://localhost:8502, backed by the SQLite DB at backend-service/data/budget.db. Interactive API docs are at http://localhost:8502/docs, and the MCP server is mounted at http://localhost:8502/mcp.

agent-service

agent-service requires a reachable Postgres instance (for LangGraph checkpointing) and an OpenRouter API key.

cd agent-service
pip install -r requirements.txt
cp .env.example .env

Edit .env and set:

  • OPENROUTER_API_KEY — required.
  • DATABASE_URL — pointing at a running Postgres instance (e.g. postgresql://postgres:postgres@localhost:5432/agent_service). You can start just the Postgres container from the compose file: docker compose up -d postgres.
  • MCP_CONFIG_PATH=mcp_servers.local.json — for local (non-docker) runs, so the agent reaches backend-service at http://localhost:8502/mcp instead of the docker-network hostname used by mcp_servers.json.

Then start it:

python src/main.py

Runs at http://localhost:8000 by default (configurable via PORT in .env). With both services running locally, backend-service must already be up so the agent can connect over MCP.

Running the entire app end-to-end with Docker

The full stack (frontend, backend, agent, Postgres, nginx, NocoDB) is defined in docker-compose.yml. From the repo root:

cp .env.example .env   # set OPENROUTER_API_KEY, NC_ADMIN_* vars (and GMAIL_* for email-ingest)
docker compose up --build

This builds and starts six containers and exposes the whole app through nginx at http://localhost:8502; frontend-service, backend-service, agent-service, and Postgres are only reachable from each other over the internal budget-network Docker network (Postgres is additionally published on localhost:5432 for convenience). NocoDB is an admin UI for both databases, published on its own port for local/Tailscale access only — not routed through nginx. Backend data persists in the external budget_data volume and agent checkpoints persist in agent_postgres_data. The seventh service, email-ingest, sits behind the cron profile, so up never starts it; run it on demand with docker compose --profile cron run --rm email-ingest.

Service URL
Whole app (via nginx) http://localhost:8502
→ frontend (UI) http://localhost:8502/
→ agent chat http://localhost:8502/chat
→ backend API/MCP http://localhost:8502/summary, /budget, /transactions, /query, /classify, /mcp, /docs
→ email transactions (GET only) http://localhost:8502/email-transactions?start=YYYY-MM-DD&end=YYYY-MM-DD
Postgres (host access) localhost:5432
NocoDB (Postgres + SQLite admin UI) localhost:8505

To push built images to Docker Hub (used for deploying to the Raspberry Pi): ./docker_push.sh <tag>.

Production deployment (Raspberry Pi)

The Pi runs the same docker-compose.yml stack described above, pulling pre-built images from Docker Hub instead of building locally. nginx is the only container with a port published to the public internet — Postgres and NocoDB publish ports on the Pi host too, but those are reachable only over Tailscale, not the public internet.

flowchart LR
    user["Browser / Penny client"] -->|":8502"| nginx

    subgraph pi["Raspberry Pi — docker compose"]
        nginx["nginx"]
        frontend["frontend-service<br/>static app.html"]
        backend["backend-service<br/>FastAPI + MCP"]
        agent["agent-service<br/>LangGraph 'Penny'"]
        pg["postgres:16<br/>checkpoints"]
        vol[("budget_data volume<br/>budget.db")]
        ingest["email-ingest<br/>one-shot, cron profile"]

        nginx -->|"/ (default)"| frontend
        nginx -->|"/summary /budget /transactions<br/>/query /classify /mcp /docs"| backend
        nginx -->|"/email-transactions<br/>(GET only)"| backend
        nginx -->|"/chat/*"| agent
        agent -.->|"MCP tool calls"| backend
        ingest -->|"POST /email-transactions<br/>(docker network, bypasses nginx)"| backend
        backend --> vol
        agent --> pg
    end

    cron["host crontab<br/>06:00 daily"] -.->|"docker compose run --rm"| ingest
    gmail[("Gmail<br/>IMAP, read-only")] --> ingest
    ingest <-->|"structured output"| openrouter["OpenRouter LLM"]
    agent <--> openrouter
Loading

Deploying is bash .claude/skills/deploy-to-pi/deploy.sh, which drives:

sequenceDiagram
    participant Dev as Dev machine
    participant Hub as Docker Hub
    participant Pi as Pi

    Dev->>Hub: 1. docker_push.sh — build + push 5 images
    Hub->>Pi: 2. docker compose --profile cron pull && up -d (recreates only changed images)
    Note over Pi: 3. docker compose restart nginx — always, not conditional
    Pi-->>Dev: 4. curl /app and /chat/sessions — expect 200
Loading

Why step 3 always runs: docker compose up -d only recreates containers whose image changed — e.g. a frontend-only deploy leaves backend-service and nginx untouched. The recreated container gets a new internal Docker IP, but nginx resolved its upstream hostnames to IPs once at its own startup and won't re-resolve them, so every route 502s until nginx is restarted — even though docker compose ps shows everything "Up".

Why --profile cron on pull: email-ingest is behind the cron profile so that up never starts it. A plain docker compose pull skips it too, and the Pi would keep running a stale ingester image.

Email-sourced bank transactions

The Bank tab shows every transaction your bank emailed you about. It is kept separate from the manually curated spend in the Spend tab, so you can reconcile the two. The data comes from email-ingest-service, a short script that runs once a day and exits. There's no agent loop, just one LLM call per email:

flowchart LR
    cron["host crontab 06:00"] --> run["docker compose --profile cron<br/>run --rm email-ingest"]
    run --> search["IMAP search (read-only)<br/>from:hdfcbank -smartstatement<br/>subject:&quot;Credit Card&quot;<br/>newer_than:2d"]
    search --> llm["per email: LLM with_structured_output<br/>→ is_transaction, amount, direction,<br/>type, description, account, date"]
    llm -->|"non-transactions dropped"| post["POST /email-transactions<br/>backend-service:8502"]
    post --> table[("email_transactions<br/>UNIQUE MessageId")]
Loading
  • Idempotent: every row stores the email's Message-ID, and the POST is INSERT OR IGNORE. The 2-day lookback (LOOKBACK_DAYS) means a missed run is picked up the next day with no duplicates.
  • Read-only mail access: the script uses stdlib imaplib, opens the mailbox with EXAMINE, and fetches with BODY.PEEK[], so alerts are never marked as read. It authenticates with a Gmail app password created only for this job, so you can revoke it without affecting anything else.
  • Amounts are signed: debits are negative and credits positive. Type is one of UPI, Credit Card, Debit Card, Net Banking or Other. A UPI payment made with the RuPay credit card counts as UPI.
  • Penny can read it through run_query, but has no tool to change it.

Configuration lives in the root .env: GMAIL_USER, GMAIL_APP_PASSWORD, OPENROUTER_API_KEY and EXTRACT_MODEL (default google/gemini-2.5-flash). To run it locally against a natively running backend-service:

cd email-ingest-service
pip install -r requirements.txt
cp .env.example .env   # fill in, BACKEND_URL=http://localhost:8502
set -a; source .env; set +a; python src/ingest.py

On the Pi, the crontab line is:

0 6 * * * cd ~/budget-tracker && docker compose --profile cron run --rm email-ingest >> email-ingest.log 2>&1

Pulling prod data down for local testing

db_backup/ is not a disaster-recovery tool for the Pi — the Pi keeps its own Docker volume and isn't restored via this repo. Instead it's for pulling the latest prod data snapshot down locally so you can test new features against real data.

  • backup_to_s3.sh is a reference copy of the script that actually runs nightly via cron on the Pi (~/budget-tracker/budget-api-db-backup.sh) — it dumps budget.db out of the running backend-service container and uploads it to S3. It isn't meant to be run from a dev machine; keep the two copies in sync by hand if you change one.
  • restore_latest_backup.py pulls the most recent S3 backup and restores it locally. Requires the aws CLI to already be authenticated (aws login), since it shells out to aws s3.
flowchart LR
    subgraph nightly["Nightly cron, on the Pi"]
        direction LR
        c1["backend-service<br/>container"] -->|"docker cp"| tmp["host /tmp"]
        tmp -->|"aws s3 cp"| s3[("s3://…-backups/<br/>backup_&lt;timestamp&gt;/budget.db")]
    end

    subgraph manual["Manual, on a dev machine"]
        direction RL
        s3b[("s3://…-backups/<br/>latest prefix")] -->|"aws s3 ls"| restore["restore_latest_backup.py"]
        restore --> dbfile["backend-service/data/budget.db"]
        restore --> sandbox[".sandbox db (if present)"]
        restore --> csv["csv_exports/ (regenerated)"]
    end
Loading

The restore path never writes back to the Pi — it's for pulling a prod-shaped dataset down to test against locally, not for recovering the Pi's own volume.

aws login   # if your session has expired
python db_backup/restore_latest_backup.py

This restores backend-service/data/budget.db (backing up whatever was already there to budget.db.bak-<timestamp> first), also refreshes the run-budget-tracker-api skill's sandboxed DB copy if present, and regenerates CSVs into db_backup/csv_exports/ for a quick human-readable look at the data.

If you're running the stack via docker compose, this restore alone has no effect on a running container: backend-service/data is mounted from the named budget_data volume, which only ever seeds from the local data/ folder once, the first time the (empty) volume is created — after that it's fully decoupled from the file on disk. To push a freshly restored DB into an already-running container:

docker cp backend-service/data/budget.db $(docker compose ps -q backend-service):/app/data/budget.db
docker compose restart backend-service

Retraining the transaction category classifier

classifier-training/ retrains the model behind /classify (classify_description in the MCP tool list) — the MiniLM embedder (int8 ONNX, via onnxruntime) + classifier pair that suggests a category from a free-text transaction description. It reads the CSV export produced by the data-pulling step above, so run that first if you want to train on the latest prod data:

flowchart LR
    csv["db_backup/csv_exports/<br/>budget_tracker.csv"] -->|"train.py"| artifacts["backend-service/models/<br/>classifier.joblib + label_encoder.joblib"]
    artifacts -->|"docker build<br/>COPY models/"| image["backend-service image"]
    artifacts -.->|"test.py<br/>(local sanity check)"| dev["dev machine"]
Loading
python db_backup/restore_latest_backup.py   # optional — pulls the latest data first
python classifier-training/train.py         # retrains, overwrites backend-service/models/*.joblib
python classifier-training/test.py          # sanity-check predictions locally

Training is deterministic (fixed RANDOM_STATE), so re-running it against unchanged data produces byte-identical model files — a no-op in git diff. Retraining is manual only; there's no cron job for it. A running backend-service caches the model in-process, so a locally running container needs a restart (see docker cp/restart above, targeting backend-service/models/ instead of data/) and the Pi needs a redeploy (deploy-to-pi skill) to pick up a newly trained model.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages