Files
fin/README.md
T
EislM0203andClaude Opus 5 c9a1375fb9 fix: close the orphan-leg hole and add idempotency keys (frontier A1 + B3)
Two long-standing correctness gaps, both flagged in fin-research-frontier.

A1 — POST /api/transactions could write half a linked pair

  TransactionCreate inherited the full six-value type Literal and accepted a
  free-standing linked_account_id, so any client could create a lone
  `transfer` row with no partner. That is exactly the balance corruption
  invariant #1 exists to prevent: the orphan counts toward SUM(amount - fee)
  and paired-delete can never find a partner to remove it with.

  TransactionCreate now narrows type to the single-leg kinds and rejects a
  caller-supplied linked_account_id. TransactionBase stays wide on purpose —
  reads must keep describing historical rows of every type.

  splitwise_pay deliberately stays allowed. The two-leg creator uses that
  type, but a standalone row carrying splitwise_partner_share is also a
  legitimate record, and is what `fin tx add --type splitwise_pay
  --partner-share` writes. Narrowing to expense/income would have broken it.

B3 — no idempotency, so any retry double-booked

  Migration i9d0e1f2a3b4 adds a nullable client_ref to transactions.
  POST /api/transactions, /batch, /transfer and /settlement accept it, and
  crud.find_by_client_ref is checked before writing, so retrying a timed-out
  request replays the original rows instead of booking them again. NULL means
  "no idempotency requested" and never collides.

  Deviation from the frontier's sketch, which called for unique-when-not-null:
  the index is NOT unique. A transfer writes two rows and a batch writes many,
  all under one key, so uniqueness cannot live on the column. Suppression is a
  check-then-write inside the creators instead — sound here because SQLite
  serialises writers and the scheduler never writes transactions.

  Exposed on the MCP create_transaction/create_transfer/create_settlement
  tools, since a retrying agent is the case B3 was written about. The server
  instructions no longer tell agents that writes are non-idempotent.

Tests: 60 passing, up from 47. Thirteen new cover retries collapsing to one
write across all four endpoints, differing keys staying separate, NULL keys
not colliding, and the orphan-leg guard refusing transfer/settlement/manual
linkage while the four legitimate single-leg types still work.

Migration verified both directions on a scratch DB seeded at the previous
head: existing rows survive with NULL client_ref, index is created, and
downgrade drops cleanly with rows intact. Verified live over HTTP that a
transfer submitted three times with one client_ref yields one pair of legs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-22 22:08:54 +00:00

4.8 KiB

fin

A self-hosted, single-user personal net worth tracker. fin aggregates bank accounts, investment portfolios, Splitwise balances, and receivables into a single EUR-denominated view, with daily automated snapshots and live stock prices.

Features

  • Dashboard — current net worth broken down by account group, plus recent transactions
  • Spending — monthly expense breakdown by category with a pie chart
  • Portfolio — investment account holdings with live USD prices (via yfinance), P&L per position, and EUR conversion
  • History — net worth over time as a line chart; snapshots taken automatically at midnight
  • Transactions — full transaction log across all accounts
  • Quick-add flows — mobile-friendly sheet for logging expenses, transfers, Splitwise splits/settlements, and stock trades
  • Settings — manage accounts (bank, investment, RSU, Splitwise, receivable) and spending categories; manual balance override per account
  • Multi-currency — EUR base currency; exchange rates via Frankfurter API

Quick Start

git clone <repo>
cd fin
docker compose up -d

The app is available at http://localhost.

Environment / Config

Set in docker-compose.yml under the api service:

Variable Default Description
DATABASE_URL sqlite:////data/fin.db SQLite path inside the container
FIN_API_TOKEN (unset — open) Bearer token the API requires; leave unset for no auth

CLI / MCP environment variables (set in the shell or MCP client config):

Variable Default Description
FIN_API_URL (required) Base URL of the fin API, e.g. https://fin.example.com
FIN_API_TOKEN (unset) Bearer token for the API (only if the API enforces auth)
FIN_MCP_ALLOW_WRITES (unset — read-only) Set to 1 to enable the 24 write tools in the MCP server
FIN_MCP_TRANSPORT stdio stdio, or streamable-http to run as a network service
FIN_MCP_HOST / FIN_MCP_PORT 0.0.0.0 / 8000 Bind address, streamable-http only
FIN_MCP_PATH /mcp Path MCP is served at, streamable-http only

Data is persisted in a named Docker volume (sqlite_data).

MCP server

fin-mcp exposes the full fin API as structured tools. Default mode is read-only (15 tools). Set FIN_MCP_ALLOW_WRITES=1 to additionally enable 24 write tools (create/update/delete transactions, accounts, stock trades, etc.).

Enum-valued arguments are typed, so the client rejects invalid values before the call. create_transaction deliberately accepts only expense and income: two-leg writes must go through create_transfer, create_settlement, create_split_expense or create_splitwise_i_paid, or the ledger ends up with an orphan leg.

Use find_account / find_category to turn a name into the integer id the other tools take. Both lists are also published as the resources fin://accounts and fin://categories.

create_transaction, create_transfer and create_settlement accept an optional client_ref. Retrying a failed call with the same key returns the write that already happened instead of booking it twice — worth setting on any write an agent might have to retry.

Transports. stdio (default) suits clients that spawn the process. streamable-http runs it as a service — it binds FIN_MCP_HOST:FIN_MCP_PORT and serves MCP at FIN_MCP_PATH:

FIN_API_URL=http://localhost:8000 FIN_MCP_TRANSPORT=streamable-http fin-mcp

In Kubernetes it runs as its own Deployment and ClusterIP Service — see deploy/k8s/fin-mcp.yaml. That manifest has no HTTPRoute on purpose: the MCP server has no auth of its own, so with writes enabled it must stay unreachable from outside the cluster unless FIN_API_TOKEN is set on both the API and the MCP server.

Install:

pip install -e 'cli/[mcp]'

Run manually:

export FIN_API_URL=https://fin.example.com
fin-mcp          # stdio; connect via your MCP client

Claude Desktop / Claude Code config (~/.claude/mcp.json or equivalent):

{
  "mcpServers": {
    "fin": {
      "command": "fin-mcp",
      "env": {
        "FIN_API_URL": "https://fin.example.com",
        "FIN_API_TOKEN": "...",
        "FIN_MCP_ALLOW_WRITES": "0"
      }
    }
  }
}

Remove FIN_API_TOKEN if the API runs without auth. Change FIN_MCP_ALLOW_WRITES to "1" to permit write operations (confirm amounts, accounts, and dates with the user before each call).

Architecture

The backend is a Python FastAPI app backed by SQLite via SQLAlchemy; APScheduler runs a nightly job to fetch stock prices (yfinance) and currency rates (Frankfurter) and record a net worth snapshot. The frontend is a React + TypeScript SPA served by Nginx, communicating with the API through the same Nginx container via reverse proxy.