Personal budget API that computes financial insights and uses AI to explain them in plain language.
- FastAPI — async Python web framework
- Supabase — managed PostgreSQL database (Clerk configured as
Third-Party Auth provider; RLS keyed on
auth.jwt() ->> 'sub') - Clerk — auth provider. Issues RS256 JWTs; backend verifies them against Clerk's JWKS endpoint. Wired up via Clerk's Supabase integration
- Pandas — data aggregation and analysis
- LiteLLM — provider-agnostic AI (Claude, GPT, Gemini, Groq)
- Pydantic v2 — data validation and settings
- Python 3.13+
- A Supabase project (free tier works)
- An API key from at least one AI provider (Anthropic, OpenAI, Google, or Groq)
git clone <your-repo-url>
cd personal-budget-apiA virtual environment keeps this project's packages
separate from your system Python. Use Python 3.13 explicitly —
macOS ships Python 3.9 as python3, and this project uses modern
union type syntax (str | None) that older Python versions cannot
parse.
/opt/homebrew/bin/python3.13 -m venv venv
source venv/bin/activateIf python3.13 is not found, install it first:
# macOS
brew install python@3.13
# or with pyenv (reads .python-version automatically)
pyenv install 3.13Verify the venv is on 3.13:
python --version # → Python 3.13.xpip install -r requirements.txtCopy the example file and fill in your keys.
cp .env.example .envOpen .env and set the required values (the app refuses to start
without these):
SUPABASE_URL— your project URL from the Supabase dashboardSUPABASE_ANON_KEY— your anon key (the per-request user JWT carries the actual authorization; the anon key is just the baseline client)SUPABASE_SERVICE_ROLE_KEY— service-role key for admin-tier ops that bypass RLS. Keep secret; never send to the frontendCLERK_ISSUER— your Clerk instance URL (e.g.https://worthy-hornet-72.clerk.accounts.dev)CLERK_SECRET_KEY— Clerk Backend API secret. Used by the account- deletion flow to call Clerk's admin API. Required at boot even whenACCOUNT_DELETION_ENABLED=falseRESEND_TEMPLATE_WELCOME— Resend template ID/alias for the welcome email (defaults towelcome-personal-budgetin.env.example)RESEND_TEMPLATE_ACCOUNT_DELETED— Resend template ID/alias for the account-deleted confirmation email (defaults todelete-personal-budget-accountin.env.example)
Optional:
CLERK_JWKS_URL— defaults to{CLERK_ISSUER}/.well-known/jwks.jsonACCOUNT_DELETION_ENABLED— feature flag for/me/delete(defaultfalse)AI_MODEL— which model to use (default:anthropic/claude-haiku-4-5-20251001)- Your AI provider's API key (e.g.
ANTHROPIC_API_KEY) CLERK_WEBHOOK_SECRET,RESEND_API_KEY,RESEND_FROM_EMAIL— only needed when the Clerk welcome webhook is wired
First activate the virtual environment (this puts uvicorn and the
project's pinned dependencies on your PATH):
source venv/bin/activateYour shell prompt should now be prefixed with (venv). Then start the
development server:
uvicorn app.main:app --reload --reload-dir app--reload-dir app scopes the file watcher to app/, so writes under
venv/, .pytest_cache/, or __pycache__/ don't trigger spurious
restarts. Override host or port with --host / --port.
The API will be available at http://localhost:8000.
Browse the interactive API docs at http://localhost:8000/docs.
To stop, press Ctrl+C. To leave the venv afterwards, run deactivate.
If you'd rather skip activation, invoke the venv's uvicorn directly:
venv/bin/uvicorn app.main:app --reload --reload-dir appA Dockerfile is included for containerised runs. The image installs
dependencies, copies the app, and starts uvicorn on $PORT (defaults
to 8000). Secrets are not baked in — pass your .env at runtime.
Build the image:
docker build -t insights-engine .Run it, mapping port 8000 and loading your local .env:
docker run --rm -p 8000:8000 --env-file .env insights-engineThe API will be available at http://localhost:8000 (docs at /docs),
same as the local dev server. To override the port, pass -e PORT=9000
and adjust the -p mapping accordingly.
ERROR: Could not find a version that satisfies the requirement fastapi==...duringpip install— yourpip3is bound to macOS's system Python 3.9, which is too old. Create the venv with Python 3.13 explicitly (/opt/homebrew/bin/python3.13 -m venv venv) and install from inside it (venv/bin/pip install -r requirements.txt).zsh: command not found: uvicorn— the venv isn't activated. Runsource venv/bin/activatefirst, or use thevenv/bin/uvicorn ...form above.TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'at startup — you're running uvicorn with macOS's bundled Python 3.9 instead of the venv's 3.13. Activate the venv (or runvenv/bin/uvicorn ...explicitly) — the error comes fromstr | Nonesyntax that requires Python 3.10+.
Requests are authenticated with Clerk JWTs (RS256). The frontend
obtains a token from the Clerk SDK and sends it as
Authorization: Bearer <token>; the backend verifies it against Clerk's
JWKS endpoint (cached per process) and enforces iss, aud, exp, and
sub claims. Supabase separately re-verifies the same token through its
Third-Party Auth (Clerk) provider, so RLS policies authorize each row by
comparing auth.jwt() ->> 'sub' to user_id.
Client (Clerk SDK)
│ Authorization: Bearer <clerk-rs256-jwt>
▼
FastAPI → get_user_ctx() → PyJWKClient (cached) → jwt.decode(RS256, iss, aud)
│ │
▼ ▼
UserContext(user_id, per-request Supabase client) 401 on any failure
│
▼
Supabase PostgREST (RLS: auth.jwt() ->> 'sub' = user_id)
All JWT verification lives in app/routes/deps.py and app/auth/jwks.py;
services never see the raw token.
In the frontend DevTools console, while signed in:
await window.Clerk.session.getToken({ template: 'supabase' })Then:
curl -H "Authorization: Bearer <token>" http://localhost:8000/insightsClerk tokens live ~60 s — refresh via the same snippet if yours expires.
Tests use pytest, which lives in requirements-dev.txt (not the
runtime requirements.txt). Install dev deps once into the venv:
venv/bin/pip install -r requirements-dev.txtThen run the suite. pytest.ini sets testpaths = tests, so no path
is needed:
# Without activating the venv
venv/bin/pytest
# Or, with the venv activated (`source venv/bin/activate`)
pytest# A single file (useful when an unrelated file fails to collect —
# e.g. tests/test_insights_route.py needs the full Settings env)
venv/bin/pytest tests/test_clerk_admin.py
# A single class
venv/bin/pytest tests/test_clerk_admin.py::TestDeleteClerkUser
# A single test
venv/bin/pytest tests/test_clerk_admin.py::TestDeleteClerkUser::test_retry_on_5xx
# Match by name substring
venv/bin/pytest -k retry-vv— verbose output, full diffs on assertion failures-s— don't capture stdout (letsprint()through)-x— stop at the first failure--lf— re-run only the tests that failed last time
Change the AI_MODEL variable in .env to swap providers
with zero code changes:
# Anthropic (default)
AI_MODEL=anthropic/claude-haiku-4-5-20251001
# OpenAI
AI_MODEL=gpt-4o-mini
# Google
AI_MODEL=gemini/gemini-1.5-flash
# Groq (has free tier)
AI_MODEL=groq/llama-3.1-8b-instantMake sure to set the matching API key for your chosen provider.
MIT