Skip to content

Repository files navigation

Fondi

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.

License: AGPL-3.0 Backend Frontend Build & push to GHCR

Resumen

⚠️ PRIVATE USE ONLY — NO REAL AUTH BOUNDARY

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 to admin if 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=1 there 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.

Contents

Features

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.

Resumen

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.

Resumen

Movimientos

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.

Movimientos

Admin

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.

Admin

Screenshots above use placeholder data for illustration, not a real fund's figures.

Mobile

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.

Resumen, mobile Movimientos, mobile Admin, mobile

How it fits together

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.

Running locally

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:8080

The 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.

Structure

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

Running via Docker

docker compose up -d --build
# Open http://localhost:8080

Uses 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 a fondi-db volume 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 /data

A 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 fondi

Reachable 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.

Deploying

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-stdin

docker-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.

Configuration

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.

Backups

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 .xlsx import, since that's the one destructive operation;
  • a periodic snapshot every BACKUP_INTERVAL_H hours (default 24, 0 disables 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.

Testing

cd backend && python -m pytest   # API, auth, import/export, share-balance rules
npm test                         # vitest — share math, per-participant figures, money input

Covered 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.

License

AGPL-3.0

About

Dashboard for managing a mutual-fund-style investment pool — contributions/withdrawals in USD, share price, ownership %, and returns in USD/COP. Vanilla JS + Chart.js frontend, FastAPI + SQLite backend, all in one self-hosted Docker image.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

Contributors

Languages