Web dashboard for managing a mutual-fund-style investment pool: several participants contribute/withdraw USD at different times, each owning a fraction of the fund measured in "shares." Shows fund value, share price, individual ownership, and returns in USD and COP.
The Admin panel's password (
ADMIN_PASSWORD) is checked server-side, so it isn't trivially bypassable from the browser, and failed attempts are rate-limited per IP (10 per 5 minutes) — but there's no session/token, and the read endpoints (/api/all,/api/export) require no auth at all: anyone who can reach the URL can read every contribution, the fund value, and each person's shares.Do not expose this to the public internet (no open port-forward, no public reverse proxy) without putting your own auth layer in front of it (e.g. a reverse proxy with basic auth, a VPN/Tailscale, etc.), and always set your own
ADMIN_PASSWORD— it defaults toadminif unset.Behind a reverse proxy every request arrives with the proxy's IP, so one person's failed attempt rate-limits everyone. Set
TRUST_PROXY=1there to count per real client (X-Forwarded-For). Leave it unset when the port is exposed directly — that header is client-supplied, and trusting it would let anyone skip the limit by sending a different one each try.
- Features
- How it fits together
- Running locally
- Running via Docker
- Deploying
- Configuration
- Backups
- Testing
- License
Modeled like a real mutual fund: every contribution/withdrawal buys "shares" at the price in effect that moment, so each participant's stake is just their share count — the fund can grow or shrink and everyone's value moves proportionally, no manual gain-splitting. Everything is an append-only log; nothing is ever edited or deleted.
Fund value and share price at a glance, each with its own % change over a range you pick (1 week to all-time) — plus a "Ganancia acumulada" view that plots the fund's accumulated gain over time as a single line, green while it's ahead of what's been contributed and red while behind, switching color exactly where it crosses zero. Below that, every participant's current value and % gain in one row each.
The full history of contributions and withdrawals, filterable by participant. Pick someone from the dropdown for their personal breakdown — current value, gain in USD and COP, total contributed — plus a chart of their investment's value against what they've put in, with its own independent date range.
Register a contribution/withdrawal or a plain valuation, with live hints as you type — the resulting COP/USD rate, the new share price — so you can sanity-check a number before saving. Also where you add/remove participants and export/import the whole dataset as .xlsx. Gated behind a password checked server-side.
Screenshots above use placeholder data for illustration, not a real fund's figures.
Below 720px the top nav becomes a bottom tab bar. Inputs are sized to avoid iOS's zoom-on-focus, and pinch-zoom is disabled.
![]() |
![]() |
![]() |
One image, one container: a multi-stage Dockerfile builds the Vite frontend, then a Python stage installs FastAPI and serves the built static files alongside the /api/* routes from a single uvicorn process — no nginx, no second container. Data lives in a SQLite file with three append-only tables (historial_fondo, movimientos, participantes_config) — nothing is ever edited or deleted, only new rows added.
The frontend (src/) is plain JS, no framework: one module per UI section under render/, all reading from a single in-memory state object (S in state.js) populated from GET /api/all. Any admin write — a movement, a valuation, adding a participant — goes through the API and then refetches and re-renders everything; there's no optimistic UI or partial state patching by design, trading snappiness for simplicity at the data volumes this app deals with.
See CLAUDE.md for the full data model, endpoint list, and the non-obvious parts of the share-price math.
Needs Node.js 20+ and Python 3.12+. The frontend and backend run as two separate processes in dev.
# Backend
cd backend
pip install -r requirements-dev.txt
ADMIN_PASSWORD=whatever uvicorn app.main:app --port 8000 --reload# Frontend, in another shell
npm install
npm run dev
# Open http://localhost:8080The frontend talks to http://localhost:8000 in dev (see API_BASE_URL in src/config.js) — CORS is enabled on the backend for this cross-origin setup. To test the UI without a running backend, set MOCK_MODE = true in src/config.js.
npm run build generates dist/ (what the Dockerfile copies into the image); npm run preview serves it locally to check before deploying.
index.html Markup only, no inline logic or styles
src/
main.js Entry point — wires up event listeners and boots the app
config.js API_BASE_URL, MOCK_MODE / mock fixtures
state.js In-memory state (S) and Chart.js instances
computed.js Derived state (share price, shares per participant, ...)
admin.js Admin panel: auth, forms, movement/valuation submission
style.css
api/backend.js fetchAll/postMovimiento/postFondo/postParticipante/exportUrl/postImportXlsx — all I/O
domain/cuotas.js The share math, pure and DOM-free so it can be unit tested
utils/ Formatters, dates, money inputs
render/ One module per UI section (summary, movements, charts)
ui/ Tabs, chart date range, error banner, refresh
*.test.js vitest, next to what they cover (domain/, computed.js, utils/)
backend/
app/main.py FastAPI app: auth dependency, routes, static file mount
app/db.py Schema + sqlite3 connection helper
app/xlsx.py xlsx export/import format
tests/ pytest + FastAPI TestClient
docker compose up -d --build
# Open http://localhost:8080Uses docker-compose.yml at the repo root (local build, no dependency on GHCR). Copy .env.example to .env and set ADMIN_PASSWORD before running — it defaults to admin otherwise.
Upgrading from an image built before the container ran as a non-root user: the process now runs as uid
10001, and afondi-dbvolume created by an older image is still owned by root, so SQLite can't write to it. The container refuses to start in that case (attempt to write a readonly database) rather than coming up healthy and failing one save at a time. Fix it once:docker run --rm -v fondi-db:/data alpine chown -R 10001:10001 /dataA volume created fresh by the current image already has the right owner.
Or build/run manually:
docker build -t fondi .
docker run -p 8080:8000 -e ADMIN_PASSWORD=whatever -v fondi-db:/data fondiReachable from any device on your network: the container listens on all interfaces, so you're not limited to localhost — find your machine's LAN IP (ipconfig getifaddr en0 on Mac, hostname -I on Linux, ipconfig on Windows) and open http://<that-ip>:8080 from your phone, tablet, or any other device on the same Wi-Fi.
Image to pull:
ghcr.io/juanhdzma/fondi:latest
# On the server where Docker runs, authenticate first (PAT needs the read:packages scope,
# created at GitHub → Settings → Developer settings → Personal access tokens):
echo <GITHUB_PAT> | docker login ghcr.io -u juanhdzma --password-stdindocker-compose.yml for a reverse-proxied deploy (e.g. via Portainer):
services:
fondi:
image: ghcr.io/juanhdzma/fondi:latest
container_name: fondi
restart: unless-stopped
environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
# There's a proxy in front, so rate-limit failed logins per real client, not per proxy IP.
- TRUST_PROXY=1
volumes:
- fondi-db:/data
networks:
- proxy
networks:
proxy:
external: true
volumes:
fondi-db:The proxy network must already exist (Traefik or another reverse proxy) and must be told to route to container port 8000. Without the fondi-db volume, the SQLite database is wiped every time the container is recreated.
After a new image is published, a plain restart/recreate is not enough — Docker won't re-fetch an already-pulled :latest tag on its own. Pull explicitly (docker compose pull, or Portainer's "re-pull image" option) before recreating.
All of it is environment variables on the backend; .env.example has the same list with the reasoning. None are required — the defaults run.
| Variable | Default | What it does |
|---|---|---|
ADMIN_PASSWORD |
admin |
Password for the Admin panel. Set your own. The default exists so a missing variable never makes the app unusable, not because it's safe. |
TRUST_PROXY |
off | Read X-Forwarded-For when counting failed logins. Turn it on only behind a reverse proxy — see the warning at the top. |
BACKUP_INTERVAL_H |
24 |
Hours between automatic DB snapshots. 0 disables them. |
ALLOWED_ORIGINS |
* |
Comma-separated CORS origins. Only matters for npm run dev, where frontend and backend are on different ports; production is same-origin. |
DB_PATH |
/data/fondi.db |
SQLite file. Must be on the mounted volume, or the history dies with the container. |
LOG_LEVEL |
INFO |
Backend log level. |
STATIC_DIR |
../static |
Where the built frontend lives inside the image. If the directory doesn't exist, no static routes are mounted and only the API is served — which is what running the backend standalone for frontend dev relies on. |
The database is the only copy of the fund's history, and the app has no UPDATE/DELETE to fix a bad write from the UI. Two things guard it, both inside the /data volume next to fondi.db (last 5 kept, named fondi.db.<timestamp>.bak):
- a snapshot taken right before every
.xlsximport, since that's the one destructive operation; - a periodic snapshot every
BACKUP_INTERVAL_Hhours (default 24,0disables it).
Both live on the same volume, so they cover corruption and bad writes — not losing the volume itself. For that, pull the export off the box on a schedule:
curl -sf http://<host>:8080/api/export -o "fondi-$(date +%F).xlsx"That file is a full restore: Admin → Datos → Importar replaces everything with its contents.
cd backend && python -m pytest # API, auth, import/export, share-balance rules
npm test # vitest — share math, per-participant figures, money inputCovered on the frontend: the share math (src/domain/), everything derived per participant (src/computed.js) and the money inputs (src/utils/). The rest — render/, admin.js, ui/ — is DOM-coupled and untested, so anything worth testing gets extracted into one of those three first rather than tested in place. No linter or type checker is configured.





