A self-hosted household ledger.
0

Configure Feed

Select the types of activity you want to include in your feed.

TypeScript 74.5%
Svelte 22.7%
CSS 1.5%
Dockerfile 0.5%
JavaScript 0.2%
HTML 0.1%
Other 0.4%
54 1 2

Clone this repository

https://git.vm.fail/graham.systems/quantum https://git.vm.fail/did:plc:tpgm6lapok6izkduegieg57j
ssh://git@knot1.tangled.sh:2222/graham.systems/quantum ssh://git@knot1.tangled.sh:2222/did:plc:tpgm6lapok6izkduegieg57j

For self-hosted knots, clone URLs may differ based on your setup.


README.md

Quantum#

Self-hosted household finance for two. Bank data flows in read-only via SimpleFIN; identity is AT Protocol OAuth (sign in with your Bluesky handle); everything lives in one SQLite file on your own hardware.

Mint-shaped: categorize transactions (by rule or by hand, with full provenance of who or what categorized every transaction), then see monthly income vs. expense per category and net worth over time.

Requirements#

  • Deno 2.9+
  • A public HTTPS origin proxied to this app (existing Caddy works fine). The ATProto authorization server must be able to fetch https://your-domain/client-metadata.json, so LAN-only won't work.
  • A SimpleFIN Bridge account with your banks connected.

Setup#

  1. Generate the OAuth signing key

    deno task generate-key
    
  2. Configure the environment — copy .env.example to .env and fill in:

    Variable Meaning
    APP_URL Public HTTPS origin, e.g. https://quantum.example.com. Drives the OAuth client identity: changing it later forces both users to re-consent.
    ALLOWED_DIDS Comma-separated DIDs allowed to log in (find yours at internect.info). Everyone else gets a 403.
    DB_PATH SQLite file path, e.g. ./data/quantum.db.
    OAUTH_PRIVATE_KEY_JWK Output of deno task generate-key, single line.
  3. Caddy route — plain reverse proxy, no special paths:

    quantum.example.com {
      reverse_proxy 127.0.0.1:3000
    }
    
  4. Build and run

    In a container (recommended for deployment; works with Podman or Docker):

    podman build -t quantum .
    podman run -d --name quantum \
      --env-file .env \
      -p 3000:3000 \
      -v quantum-data:/data \
      quantum
    

    The container reads APP_URL, ALLOWED_DIDS, and OAUTH_PRIVATE_KEY_JWK from the environment and stores the database on the quantum-data named volume (DB_PATH defaults to /data/quantum.db inside). Back up that volume — it is the only state. To start on boot, generate a systemd unit (podman generate systemd --new quantum) or use a Quadlet.

    Or directly on the host:

    deno install        # once, after every dependency change
    deno task build
    PORT=3000 deno task start
    

    Either way, migrations apply automatically at startup. When running on the host, run from the repo root (the migrations/ directory and .env are resolved relative to it).

  5. First run — log in with an allowlisted handle, open Settings, paste a SimpleFIN setup token (single-use; generate it at the Bridge), and the first sync backfills what your banks provide (typically ~90 days). New accounts land in Accounts → Needs setup for one-time classification.

Day-to-day: a daily sync runs automatically (Deno.cron); Sync now lives in Settings. Bank connections are managed at the Bridge, never in the app — Quantum surfaces connection problems and links you there.

Development#

deno task dev     # dev server on http://localhost:5173 (uses a loopback OAuth client)
deno task test    # unit tests (sync, reconciliation, rules, reports)
deno task check   # svelte-check

Troubleshooting: "unable to open database file"#

The container runs as the non-root deno user (uid 1000), so /data must be writable by it:

  • Named volume (-v quantum-data:/data) — works out of the box; Podman and Docker copy the image's ownership into a fresh volume.
  • Bind mount (-v /srv/quantum:/data) — the host directory keeps its host ownership. With rootless Podman, either add the :U option (-v /srv/quantum:/data:U, chowns to the container user) or run podman unshare chown -R 1000:1000 /srv/quantum once. On SELinux hosts (Fedora and friends) you also want :Z, i.e. :U,Z.
  • A volume first created by some other image/user keeps its old ownership; podman volume inspect shows its path if you need to fix it up.

Operational notes#

  • The database file is secret-grade. It contains the SimpleFIN Access URL (a permanent credential for read-only bank access) and your full financial history. Restrict file permissions and treat backups with the same care.
  • Back up DB_PATH (plus its -wal/-shm siblings, or use sqlite3 .backup). Balance history powers the net-worth chart and cannot be re-fetched: losing the file loses it. Under Docker that means the quantum-data volume.
  • Transaction backfill is limited to what banks return at first sync (~90 days). Balance snapshots start on day one and only grow.
  • All money is stored as integer cents; 2-decimal currencies are assumed.
  • Raw SimpleFIN responses are archived verbatim in raw_syncs — normalization can be replayed against history if the schema evolves.