feat: fin CLI + agent skill + opt-in API bearer auth

Adds `fin` — a typer/httpx CLI for the deployed API — so Claude Code and
other agents can consume fin over HTTP without an MCP server.

Backend:
- api/app/auth.py: require_api_token dependency (no-op unless FIN_API_TOKEN
  env is set; RFC 7235 case-insensitive Bearer, constant-time compare). All
  nine routers wired; /api/health exempt for k8s probes.
- GET /api/openapi.json added as an explicit token-gated route (FastAPI's
  built-in openapi_url bypasses dependency injection).
- GET /api/transactions gains an optional `limit` query param (ge=1).

CLI (cli/):
- fin_cli/client.py: FinClient — sync httpx wrapper, zero typer/rich imports
  (MCP-ready core for a future MCP server).
- Subcommands: networth, accounts, categories, tx, transfer, settle,
  split-expense, splitwise-paid, reconcile, holdings, stock, rsu,
  spending, prices. Every command supports --json for agent use.
- fin tx add refuses transfer/settlement types to prevent single-leg writes.
- Config via FIN_API_URL (required) and FIN_API_TOKEN (optional).

Agent skill: .claude/skills/fin/SKILL.md — command map, jq patterns, and
domain invariants (two-leg atomicity, net-share splitwise math, derived
holdings, funded-buy, soft-deactivate, FIN_API_TOKEN web-UI lockout warning).

Tests: 25 e2e tests (FinClient → real routes → temp SQLite) covering auth
on/off, all key invariants (two-leg linkage, fee-in-balance, net-share math),
transfer whole-group delete, and linked-leg edit rejection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPBt3s7Jyr2nozQTrVVGA4
This commit is contained in:
EislM0203
2026-07-03 19:22:15 +00:00
co-authored by Claude Sonnet 4.6
parent e36d719cbe
commit 082b6081b7
21 changed files with 1552 additions and 12 deletions
+58
View File
@@ -0,0 +1,58 @@
---
name: fin
description: Interact with the user's fin personal-finance app (net worth, accounts, transactions, spending analytics, stock portfolio, RSU grants) via the `fin` CLI. Use whenever the user asks about their finances, wants to record expenses/transfers/stock trades, or query spending and net worth.
---
# fin CLI
`fin` talks HTTP to the fin API. Setup (usually already exported):
```bash
export FIN_API_URL=https://<ingress-host> # required, no default
export FIN_API_TOKEN=<token> # only if the API enforces auth
```
Note: enabling `FIN_API_TOKEN` on the API side also locks out the React web UI (it sends no `Authorization` header) until nginx injects the header or the frontend is taught the token — warn the user before suggesting it.
Install once: `pip install -e cli/` (from the repo root). Verify with `fin health`.
## Output
Every command accepts `--json`. **Always use `--json` and filter with `jq`** — human tables are for the user, not for you.
```bash
fin tx list --month 2026-06 --json | jq '[.[] | select(.type=="expense")] | length'
fin networth --json | jq '.total_eur'
```
## Command map
| Task | Command |
|---|---|
| Current net worth | `fin networth` |
| Net worth over time | `fin networth history --range 1M\|3M\|6M\|1Y\|all` |
| Accounts | `fin accounts list\|create\|update\|deactivate\|adjust-balance` |
| Categories | `fin categories list\|create\|update\|delete` |
| Transactions | `fin tx list [--account N] [--category N] [--month YYYY-MM] [--limit N]`, `fin tx add`, `fin tx update`, `fin tx rm` |
| Move money between accounts | `fin transfer --from N --to N --amount X --date D` (FX: add `--to-amount`/`--to-currency`) |
| Splitwise settle-up | `fin settle --bank N --splitwise N --amount X --date D --direction bank_to_splitwise\|splitwise_to_bank` |
| Shared bill I paid (partner owes me) | `fin split-expense --bank N --receivable N --total X --partner-share Y --date D` |
| Splitwise "I paid" | `fin splitwise-paid --bank N --splitwise N --total X --partner-share Y --date D` |
| Settle receivable/payable | `fin reconcile --bank N --other N --amount X --date D` |
| Portfolio for an account | `fin holdings <account-id>` |
| Stock trades | `fin stock list\|add\|funded-buy\|rm` |
| RSU grants | `fin rsu list\|add\|vest\|rm`, `fin rsu schedule preview\|generate` |
| Spending | `fin spending month YYYY-MM [--account N]`, `fin spending summary --year YYYY` or `--start D --end D` |
| Prices / FX | `fin prices fx\|refresh\|fetch TICKER` |
Discover account and category ids first: `fin accounts list --json`, `fin categories list --json`. Expense amounts are **negative**, income positive.
## Invariants — do not violate
- **Transfers and settlements are atomic two-leg pairs.** `fin tx rm <id>` on any leg deletes the whole linked group. Never try to remove or edit a single leg (edits on linked legs are rejected by the API — delete the group and re-enter). `fin tx add` deliberately refuses `transfer`/`settlement` types — a single unlinked leg corrupts balances; use `fin transfer` / `fin settle`.
- **Holdings are derived state**, recomputed from stock transaction history. Never adjust a holding directly; add or delete stock transactions instead.
- **Buying stock from a bank account = `fin stock funded-buy`** (creates the stock row and the bank transfer leg atomically). Plain `fin stock add` records the trade with no cash movement.
- **Splitwise spending math is intentional**: a `splitwise_pay` row counts as `amount + splitwise_partner_share` (the user's net share) in spending. Do not "fix" totals that look off by the partner's share.
- **"I-Owe" counts as spending by design** — don't exclude it when summing.
- **Account deletion is soft**: `fin accounts deactivate` deactivates; there is no hard delete and deactivated accounts disappear from `fin accounts list`. Reactivate with `fin accounts update <id> --is-active` — note the id before deactivating, since the list no longer shows it.
- **Reconcile is not income/spending**: settling receivables/payables moves cash between own accounts; the consumption was booked when the receivable/payable was created.
+9
View File
@@ -41,8 +41,17 @@ web/
QuickAdd/flows/ # One component per transaction type (Expense, Transfer, etc.)
layout/ # Shell layout with nav
ui/ # shadcn/ui components (Button, Card, Dialog, Input, Select…)
cli/
fin_cli/
client.py # FinClient — plain httpx wrapper, no CLI deps (MCP-ready core)
main.py # typer entry point (`fin` command), subcommand modules alongside
tests/ # pytest e2e: FinClient → real app via TestClient → SQLite
```
## CLI (`cli/`)
`fin` is a typer CLI that talks HTTP to the deployed API. Configure with `FIN_API_URL` (required, no default) and `FIN_API_TOKEN` (only if the API enforces auth — the API checks it only when its own `FIN_API_TOKEN` env var is set; `/api/health` is always open). **Warning**: enabling `FIN_API_TOKEN` on the API also locks out the React frontend — `web/src/api/client.ts` sends no `Authorization` header — until nginx injects the header or the frontend learns the token. Install with `pip install -e cli/`; every command supports `--json`. `fin_cli/client.py` is kept free of typer/rich imports so a future MCP server can reuse it as-is. Agent-facing usage docs live in `.claude/skills/fin/SKILL.md`.
## Backend conventions
- **Sync only**: SQLAlchemy is configured with a synchronous engine (`create_engine`, `SessionLocal`). Never introduce `async def` endpoints or `async with Session`. FastAPI handles the thread pool.
+30
View File
@@ -0,0 +1,30 @@
import os
import secrets
from typing import Optional
from fastapi import Header, HTTPException
def require_api_token(authorization: Optional[str] = Header(default=None)) -> None:
"""Opt-in bearer token check.
Enforced only when FIN_API_TOKEN is set in the environment; otherwise a
no-op so existing deployments keep working. Read at request time so the
token can be rotated without code changes.
"""
expected = os.getenv("FIN_API_TOKEN", "")
if not expected:
return
scheme, _, provided = (authorization or "").partition(" ")
if scheme.lower() != "bearer" or not provided:
raise HTTPException(
status_code=401,
detail="Missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
if not secrets.compare_digest(provided.encode(), expected.encode()):
raise HTTPException(
status_code=401,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
+20 -10
View File
@@ -1,8 +1,9 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from app.auth import require_api_token
from app.database import Base, engine, SessionLocal
from app.db_config import get_database_url
from app import models
@@ -61,12 +62,21 @@ def health_check():
return {"status": "ok"}
app.include_router(accounts.router)
app.include_router(categories.router)
app.include_router(transactions.router)
app.include_router(snapshots.router)
app.include_router(prices.router)
app.include_router(holdings.router)
app.include_router(stock_transactions.router)
app.include_router(spending.router)
app.include_router(rsu_grants.router)
# FastAPI's built-in openapi_url route bypasses dependency injection, so the
# spec is served from an explicit route that honors the token check.
@app.get("/api/openapi.json", include_in_schema=False, dependencies=[Depends(require_api_token)])
def openapi_spec():
return app.openapi()
_auth = [Depends(require_api_token)]
app.include_router(accounts.router, dependencies=_auth)
app.include_router(categories.router, dependencies=_auth)
app.include_router(transactions.router, dependencies=_auth)
app.include_router(snapshots.router, dependencies=_auth)
app.include_router(prices.router, dependencies=_auth)
app.include_router(holdings.router, dependencies=_auth)
app.include_router(stock_transactions.router, dependencies=_auth)
app.include_router(spending.router, dependencies=_auth)
app.include_router(rsu_grants.router, dependencies=_auth)
+3 -2
View File
@@ -1,6 +1,6 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -15,9 +15,10 @@ def list_transactions(
account_id: Optional[int] = None,
category_id: Optional[int] = None,
month: Optional[str] = None,
limit: Optional[int] = Query(default=None, ge=1),
db: Session = Depends(get_db),
):
return crud.get_transactions(db, account_id=account_id, category_id=category_id, month=month)
return crud.get_transactions(db, account_id=account_id, category_id=category_id, month=month, limit=limit)
@router.post("", response_model=schemas.TransactionRead, status_code=201)
+1
View File
@@ -0,0 +1 @@
"""fin-cli — command-line client for the fin personal finance API."""
+87
View File
@@ -0,0 +1,87 @@
from typing import Optional
import typer
from fin_cli.output import run
app = typer.Typer(help="Manage accounts.")
ACCOUNT_TYPES = ["bank", "investment", "rsu", "splitwise", "receivable", "payable"]
@app.command("list")
def list_accounts(as_json: bool = typer.Option(False, "--json")):
"""List active accounts."""
run(lambda c: c.list_accounts(), as_json)
@app.command()
def create(
name: str = typer.Argument(..., help="Account name."),
type: str = typer.Option(..., "--type", help=f"One of: {', '.join(ACCOUNT_TYPES)}."),
currency: str = typer.Option("EUR", "--currency"),
notes: Optional[str] = typer.Option(None, "--notes"),
initial_balance: Optional[float] = typer.Option(
None, "--initial-balance", help="Creates an opening-balance income transaction."
),
as_json: bool = typer.Option(False, "--json"),
):
"""Create an account."""
data = {"name": name, "type": type, "currency": currency}
if notes is not None:
data["notes"] = notes
if initial_balance is not None:
data["initial_balance"] = initial_balance
run(lambda c: c.create_account(data), as_json)
@app.command()
def update(
account_id: int = typer.Argument(...),
name: Optional[str] = typer.Option(None, "--name"),
currency: Optional[str] = typer.Option(None, "--currency"),
notes: Optional[str] = typer.Option(None, "--notes"),
manual_override_balance: Optional[float] = typer.Option(None, "--override-balance"),
is_active: Optional[bool] = typer.Option(
None,
"--is-active/--no-is-active",
help="Reactivate (or deactivate) an account. Deactivated accounts are "
"hidden from `fin accounts list`, so note the id before deactivating.",
),
as_json: bool = typer.Option(False, "--json"),
):
"""Update account fields (only the flags you pass are changed)."""
data = {
k: v
for k, v in {
"name": name,
"currency": currency,
"notes": notes,
"manual_override_balance": manual_override_balance,
"is_active": is_active,
}.items()
if v is not None
}
if not data:
typer.secho("Nothing to update — pass at least one field flag.", err=True, fg=typer.colors.RED)
raise typer.Exit(1)
run(lambda c: c.update_account(account_id, data), as_json)
@app.command()
def deactivate(
account_id: int = typer.Argument(...),
as_json: bool = typer.Option(False, "--json"),
):
"""Soft-deactivate an account (no hard delete exists)."""
run(lambda c: c.deactivate_account(account_id), as_json)
@app.command("adjust-balance")
def adjust_balance(
account_id: int = typer.Argument(...),
target_balance: float = typer.Argument(..., help="Desired balance after adjustment."),
as_json: bool = typer.Option(False, "--json"),
):
"""Insert an income/expense so the account balance equals the target."""
run(lambda c: c.adjust_account_balance(account_id, target_balance), as_json)
+50
View File
@@ -0,0 +1,50 @@
from typing import Optional
import typer
from fin_cli.output import run
app = typer.Typer(help="Manage spending categories.")
@app.command("list")
def list_categories(as_json: bool = typer.Option(False, "--json")):
"""List all categories."""
run(lambda c: c.list_categories(), as_json)
@app.command()
def create(
name: str = typer.Argument(...),
color: str = typer.Option(..., "--color", help="Hex color, e.g. #22c55e."),
icon: str = typer.Option(..., "--icon", help="Emoji icon."),
type: str = typer.Option("expense", "--type", help="expense or income."),
as_json: bool = typer.Option(False, "--json"),
):
"""Create a category."""
run(lambda c: c.create_category({"name": name, "color": color, "icon": icon, "type": type}), as_json)
@app.command()
def update(
category_id: int = typer.Argument(...),
name: Optional[str] = typer.Option(None, "--name"),
color: Optional[str] = typer.Option(None, "--color"),
icon: Optional[str] = typer.Option(None, "--icon"),
as_json: bool = typer.Option(False, "--json"),
):
"""Update category fields."""
data = {k: v for k, v in {"name": name, "color": color, "icon": icon}.items() if v is not None}
if not data:
typer.secho("Nothing to update — pass at least one field flag.", err=True, fg=typer.colors.RED)
raise typer.Exit(1)
run(lambda c: c.update_category(category_id, data), as_json)
@app.command()
def delete(
category_id: int = typer.Argument(...),
as_json: bool = typer.Option(False, "--json"),
):
"""Delete a category (transactions keep existing, uncategorized)."""
run(lambda c: c.delete_category(category_id), as_json)
+240
View File
@@ -0,0 +1,240 @@
"""HTTP client for the fin API.
Deliberately free of CLI dependencies (typer/rich) so it can back other
frontends later, e.g. an MCP server.
"""
import os
from typing import Any, Optional
import httpx
class FinClientError(RuntimeError):
def __init__(self, message: str, status_code: Optional[int] = None):
super().__init__(message)
self.status_code = status_code
class FinClient:
"""Thin sync wrapper around the fin HTTP API.
Configuration:
base_url — explicit, or FIN_API_URL env var (required; no default so a
misconfigured shell can never silently hit the wrong host)
token — explicit, or FIN_API_TOKEN env var (optional)
Either an httpx transport or a fully built httpx.Client can be injected
for testing.
"""
def __init__(
self,
base_url: Optional[str] = None,
token: Optional[str] = None,
transport: Optional[httpx.BaseTransport] = None,
http: Optional[httpx.Client] = None,
):
if http is not None:
self._http = http
return
base_url = base_url or os.getenv("FIN_API_URL", "")
if not base_url:
raise FinClientError(
"FIN_API_URL is not set. Export it to your fin API base URL, "
"e.g. FIN_API_URL=https://fin.example.com"
)
token = token if token is not None else os.getenv("FIN_API_TOKEN", "")
headers = {"Authorization": f"Bearer {token}"} if token else {}
self._http = httpx.Client(
base_url=base_url.rstrip("/"),
headers=headers,
timeout=30.0,
transport=transport,
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
try:
resp = self._http.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise FinClientError(f"Request to fin API failed: {exc}") from exc
if resp.status_code >= 400:
try:
detail = resp.json().get("detail", resp.text)
except ValueError:
detail = resp.text
raise FinClientError(
f"API error {resp.status_code}: {detail}", status_code=resp.status_code
)
if resp.status_code == 204 or not resp.content:
return None
return resp.json()
def _get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
if params:
params = {k: v for k, v in params.items() if v is not None}
return self._request("GET", path, params=params or None)
def _post(self, path: str, json: Optional[dict[str, Any]] = None,
params: Optional[dict[str, Any]] = None) -> Any:
return self._request("POST", path, json=json, params=params)
def _put(self, path: str, json: dict[str, Any]) -> Any:
return self._request("PUT", path, json=json)
def _delete(self, path: str) -> Any:
return self._request("DELETE", path)
# -- health / meta -----------------------------------------------------
def health(self) -> Any:
return self._get("/api/health")
def openapi_spec(self) -> Any:
return self._get("/api/openapi.json")
# -- net worth ----------------------------------------------------------
def get_net_worth(self) -> Any:
return self._get("/api/net-worth/current")
def get_net_worth_history(self, range: str = "all") -> Any:
return self._get("/api/net-worth/history", params={"range": range})
# -- accounts -----------------------------------------------------------
def list_accounts(self) -> Any:
return self._get("/api/accounts")
def create_account(self, data: dict[str, Any]) -> Any:
return self._post("/api/accounts", json=data)
def update_account(self, account_id: int, data: dict[str, Any]) -> Any:
return self._put(f"/api/accounts/{account_id}", json=data)
def deactivate_account(self, account_id: int) -> Any:
return self._delete(f"/api/accounts/{account_id}")
def adjust_account_balance(self, account_id: int, target_balance: float) -> Any:
return self._post(
f"/api/accounts/{account_id}/adjust-balance",
json={"target_balance": target_balance},
)
# -- categories ----------------------------------------------------------
def list_categories(self) -> Any:
return self._get("/api/categories")
def create_category(self, data: dict[str, Any]) -> Any:
return self._post("/api/categories", json=data)
def update_category(self, category_id: int, data: dict[str, Any]) -> Any:
return self._put(f"/api/categories/{category_id}", json=data)
def delete_category(self, category_id: int) -> Any:
return self._delete(f"/api/categories/{category_id}")
# -- transactions ----------------------------------------------------------
def list_transactions(
self,
account_id: Optional[int] = None,
category_id: Optional[int] = None,
month: Optional[str] = None,
limit: Optional[int] = None,
) -> Any:
return self._get(
"/api/transactions",
params={
"account_id": account_id,
"category_id": category_id,
"month": month,
"limit": limit,
},
)
def create_transaction(self, data: dict[str, Any]) -> Any:
return self._post("/api/transactions", json=data)
def update_transaction(self, transaction_id: int, data: dict[str, Any]) -> Any:
return self._put(f"/api/transactions/{transaction_id}", json=data)
def delete_transaction(self, transaction_id: int) -> Any:
return self._delete(f"/api/transactions/{transaction_id}")
def create_transfer(self, data: dict[str, Any]) -> Any:
return self._post("/api/transactions/transfer", json=data)
def create_settlement(self, data: dict[str, Any]) -> Any:
return self._post("/api/transactions/settlement", json=data)
def create_split_expense(self, data: dict[str, Any]) -> Any:
return self._post("/api/transactions/split-expense", json=data)
def create_splitwise_i_paid(self, data: dict[str, Any]) -> Any:
return self._post("/api/transactions/splitwise-i-paid", json=data)
def reconcile(self, data: dict[str, Any]) -> Any:
return self._post("/api/transactions/reconcile", json=data)
# -- holdings / stock transactions ---------------------------------------
def get_holdings(self, account_id: int) -> Any:
return self._get(f"/api/holdings/{account_id}")
def list_stock_transactions(self, account_id: Optional[int] = None) -> Any:
return self._get("/api/stock-transactions", params={"account_id": account_id})
def create_stock_transaction(self, data: dict[str, Any]) -> Any:
return self._post("/api/stock-transactions", json=data)
def funded_buy(self, data: dict[str, Any]) -> Any:
return self._post("/api/stock-transactions/funded-buy", json=data)
def delete_stock_transaction(self, stock_txn_id: int) -> Any:
return self._delete(f"/api/stock-transactions/{stock_txn_id}")
# -- RSU grants ----------------------------------------------------------
def list_rsu_grants(self, include_vested: bool = False) -> Any:
return self._get("/api/rsu-grants", params={"include_vested": include_vested})
def create_rsu_grant(self, data: dict[str, Any]) -> Any:
return self._post("/api/rsu-grants", json=data)
def delete_rsu_grant(self, grant_id: int) -> Any:
return self._delete(f"/api/rsu-grants/{grant_id}")
def vest_rsu_grant(self, grant_id: int, data: Optional[dict[str, Any]] = None) -> Any:
return self._post(f"/api/rsu-grants/{grant_id}/vest", json=data or {})
def preview_rsu_schedule(self, data: dict[str, Any]) -> Any:
return self._post("/api/rsu-grants/preview-schedule", json=data)
def generate_rsu_schedule(self, data: dict[str, Any]) -> Any:
return self._post("/api/rsu-grants/generate-schedule", json=data)
# -- spending ----------------------------------------------------------
def get_spending_month(self, month: str, account_id: Optional[int] = None) -> Any:
return self._get(
"/api/spending/monthly", params={"month": month, "account_id": account_id}
)
def get_spending_summary(self, **params: Any) -> Any:
return self._get("/api/spending/summary", params=params)
# -- prices ----------------------------------------------------------
def get_fx_rate(self, from_currency: str = "EUR", to_currency: str = "USD") -> Any:
return self._get(
"/api/prices/fx-rate",
params={"from_currency": from_currency, "to_currency": to_currency},
)
def refresh_prices(self) -> Any:
return self._post("/api/prices/refresh")
def fetch_ticker_price(self, ticker: str) -> Any:
return self._post("/api/prices/fetch-ticker", params={"ticker": ticker})
+145
View File
@@ -0,0 +1,145 @@
"""Multi-leg transaction flows: transfer, settle, split-expense, splitwise-paid, reconcile.
Each creates linked two-leg (or three-row) groups atomically on the server.
"""
from typing import Optional
import typer
from fin_cli.output import run
def transfer(
from_account: int = typer.Option(..., "--from", help="Source account id."),
to_account: int = typer.Option(..., "--to", help="Destination account id."),
amount: float = typer.Option(..., "--amount"),
date: str = typer.Option(..., "--date", help="YYYY-MM-DD."),
currency: str = typer.Option("EUR", "--currency"),
fee: float = typer.Option(0, "--fee"),
description: str = typer.Option("Transfer", "--description"),
to_amount: Optional[float] = typer.Option(
None, "--to-amount", help="Amount credited on the destination (FX transfers)."
),
to_currency: Optional[str] = typer.Option(
None, "--to-currency", help="Destination currency (FX transfers)."
),
as_json: bool = typer.Option(False, "--json"),
):
"""Move money between two accounts (creates both legs atomically)."""
data = {
"from_account_id": from_account,
"to_account_id": to_account,
"amount": amount,
"date": date,
"currency": currency,
"fee": fee,
"description": description,
}
if to_amount is not None:
data["to_amount"] = to_amount
if to_currency is not None:
data["to_currency"] = to_currency
run(lambda c: c.create_transfer(data), as_json)
def settle(
bank_account: int = typer.Option(..., "--bank", help="Bank account id."),
splitwise_account: int = typer.Option(..., "--splitwise", help="Splitwise account id."),
amount: float = typer.Option(..., "--amount"),
date: str = typer.Option(..., "--date", help="YYYY-MM-DD."),
direction: str = typer.Option(
..., "--direction", help="bank_to_splitwise or splitwise_to_bank."
),
currency: str = typer.Option("EUR", "--currency"),
as_json: bool = typer.Option(False, "--json"),
):
"""Settle a Splitwise balance against a bank account."""
run(
lambda c: c.create_settlement(
{
"bank_account_id": bank_account,
"splitwise_account_id": splitwise_account,
"amount": amount,
"date": date,
"direction": direction,
"currency": currency,
}
),
as_json,
)
def split_expense(
bank_account: int = typer.Option(..., "--bank", help="Bank account id that paid."),
receivable_account: int = typer.Option(..., "--receivable", help="Receivable account id."),
total_amount: float = typer.Option(..., "--total", help="Full bill amount."),
partner_share: float = typer.Option(..., "--partner-share", help="Partner's portion of the bill."),
date: str = typer.Option(..., "--date", help="YYYY-MM-DD."),
currency: str = typer.Option("EUR", "--currency"),
category: Optional[int] = typer.Option(None, "--category"),
description: Optional[str] = typer.Option(None, "--description"),
as_json: bool = typer.Option(False, "--json"),
):
"""You paid a shared bill; partner's share becomes a receivable."""
data = {
"bank_account_id": bank_account,
"receivable_account_id": receivable_account,
"total_amount": total_amount,
"partner_share": partner_share,
"date": date,
"currency": currency,
}
if category is not None:
data["category_id"] = category
if description is not None:
data["description"] = description
run(lambda c: c.create_split_expense(data), as_json)
def splitwise_paid(
bank_account: int = typer.Option(..., "--bank", help="Bank account id that paid."),
splitwise_account: int = typer.Option(..., "--splitwise", help="Splitwise account id."),
total_amount: float = typer.Option(..., "--total", help="Full bill amount."),
partner_share: float = typer.Option(..., "--partner-share", help="Partner's portion of the bill."),
date: str = typer.Option(..., "--date", help="YYYY-MM-DD."),
currency: str = typer.Option("EUR", "--currency"),
category: Optional[int] = typer.Option(None, "--category"),
description: Optional[str] = typer.Option(None, "--description"),
as_json: bool = typer.Option(False, "--json"),
):
"""You paid the full bill via Splitwise ('I paid' flow)."""
data = {
"bank_account_id": bank_account,
"splitwise_account_id": splitwise_account,
"total_amount": total_amount,
"partner_share": partner_share,
"date": date,
"currency": currency,
}
if category is not None:
data["category_id"] = category
if description is not None:
data["description"] = description
run(lambda c: c.create_splitwise_i_paid(data), as_json)
def reconcile(
bank_account: int = typer.Option(..., "--bank", help="Bank account id."),
other_account: int = typer.Option(..., "--other", help="Receivable or payable account id."),
amount: float = typer.Option(..., "--amount"),
date: str = typer.Option(..., "--date", help="YYYY-MM-DD."),
description: Optional[str] = typer.Option(None, "--description"),
as_json: bool = typer.Option(False, "--json"),
):
"""Settle a receivable/payable against a bank account (settlement legs,
does not count as income or spending)."""
data = {
"bank_account_id": bank_account,
"other_account_id": other_account,
"amount": amount,
"date": date,
}
if description is not None:
data["description"] = description
run(lambda c: c.reconcile(data), as_json)
+47
View File
@@ -0,0 +1,47 @@
import typer
from fin_cli import accounts, categories, flows, networth, prices, rsu, spending, stock, tx
from fin_cli.output import run
app = typer.Typer(
help=(
"CLI for the fin personal finance API. "
"Configure with FIN_API_URL (required) and FIN_API_TOKEN (if the API requires it). "
"Every command supports --json for machine-readable output."
),
no_args_is_help=True,
)
app.add_typer(networth.app, name="networth")
app.add_typer(accounts.app, name="accounts")
app.add_typer(categories.app, name="categories")
app.add_typer(tx.app, name="tx")
app.add_typer(stock.app, name="stock")
app.add_typer(rsu.app, name="rsu")
app.add_typer(spending.app, name="spending")
app.add_typer(prices.app, name="prices")
app.command()(flows.transfer)
app.command()(flows.settle)
app.command("split-expense")(flows.split_expense)
app.command("splitwise-paid")(flows.splitwise_paid)
app.command()(flows.reconcile)
@app.command()
def holdings(
account_id: int = typer.Argument(..., help="Investment or RSU account id."),
as_json: bool = typer.Option(False, "--json"),
):
"""Holdings for one account, enriched with live P&L."""
run(lambda c: c.get_holdings(account_id), as_json)
@app.command()
def health(as_json: bool = typer.Option(False, "--json")):
"""Check the API is reachable."""
run(lambda c: c.health(), as_json)
if __name__ == "__main__":
app()
+25
View File
@@ -0,0 +1,25 @@
import typer
from fin_cli.output import run
app = typer.Typer(help="Net worth (live and historical).", invoke_without_command=True)
@app.callback()
def current(
ctx: typer.Context,
as_json: bool = typer.Option(False, "--json", help="Raw JSON output."),
):
"""Show current net worth (default when no subcommand given)."""
if ctx.invoked_subcommand is not None:
return
run(lambda c: c.get_net_worth(), as_json)
@app.command()
def history(
range: str = typer.Option("all", "--range", help="1M, 3M, 6M, 1Y or all."),
as_json: bool = typer.Option(False, "--json", help="Raw JSON output."),
):
"""Net worth snapshots over time."""
run(lambda c: c.get_net_worth_history(range=range), as_json)
+68
View File
@@ -0,0 +1,68 @@
"""Output helpers shared by all CLI commands."""
import json
from typing import Any, Callable
import typer
from rich.console import Console
from rich.table import Table
from fin_cli.client import FinClient, FinClientError
console = Console()
def get_client() -> FinClient:
try:
return FinClient()
except FinClientError as exc:
typer.secho(str(exc), err=True, fg=typer.colors.RED)
raise typer.Exit(1)
def run(fn: Callable[[FinClient], Any], as_json: bool) -> None:
"""Create a client, execute one API call, print the result."""
client = get_client()
try:
data = fn(client)
except FinClientError as exc:
typer.secho(str(exc), err=True, fg=typer.colors.RED)
raise typer.Exit(1)
print_result(data, as_json)
def print_result(data: Any, as_json: bool) -> None:
if as_json:
print(json.dumps(data, indent=2, default=str))
return
if data is None:
console.print("[green]OK[/green]")
return
if isinstance(data, list) and data and all(isinstance(row, dict) for row in data):
_print_table(data)
return
console.print(data)
def _print_table(rows: list[dict[str, Any]]) -> None:
columns: list[str] = []
for row in rows:
for key in row:
if key not in columns:
columns.append(key)
table = Table(show_header=True, header_style="bold")
for col in columns:
table.add_column(col)
for row in rows:
table.add_row(*(_cell(row.get(col)) for col in columns))
console.print(table)
def _cell(value: Any) -> str:
if value is None:
return ""
if isinstance(value, float):
return f"{value:,.2f}"
if isinstance(value, (dict, list)):
return json.dumps(value, default=str)
return str(value)
+30
View File
@@ -0,0 +1,30 @@
import typer
from fin_cli.output import run
app = typer.Typer(help="Prices and FX rates.")
@app.command()
def fx(
from_currency: str = typer.Option("EUR", "--from"),
to_currency: str = typer.Option("USD", "--to"),
as_json: bool = typer.Option(False, "--json"),
):
"""Read a cached FX rate."""
run(lambda c: c.get_fx_rate(from_currency, to_currency), as_json)
@app.command()
def refresh(as_json: bool = typer.Option(False, "--json")):
"""Queue a background refresh of all tracked tickers and FX rates."""
run(lambda c: c.refresh_prices(), as_json)
@app.command()
def fetch(
ticker: str = typer.Argument(...),
as_json: bool = typer.Option(False, "--json"),
):
"""Fetch and cache one ticker price immediately (blocks on yfinance)."""
run(lambda c: c.fetch_ticker_price(ticker), as_json)
+121
View File
@@ -0,0 +1,121 @@
from typing import Optional
import typer
from fin_cli.output import run
app = typer.Typer(help="RSU grants and vesting.")
schedule_app = typer.Typer(help="Preview or generate a full vesting schedule.")
app.add_typer(schedule_app, name="schedule")
@app.command("list")
def list_grants(
include_vested: bool = typer.Option(False, "--include-vested"),
as_json: bool = typer.Option(False, "--json"),
):
"""List RSU grants (pending by default)."""
run(lambda c: c.list_rsu_grants(include_vested=include_vested), as_json)
@app.command()
def add(
account: int = typer.Option(..., "--account", help="RSU account id."),
ticker: str = typer.Option(..., "--ticker"),
vest_date: str = typer.Option(..., "--vest-date", help="YYYY-MM-DD."),
gross_shares: float = typer.Option(..., "--gross-shares"),
withholding_pct: float = typer.Option(0.5, "--withholding-pct", help="0..1."),
notes: Optional[str] = typer.Option(None, "--notes"),
as_json: bool = typer.Option(False, "--json"),
):
"""Create a single RSU grant."""
data = {
"account_id": account,
"ticker": ticker,
"vest_date": vest_date,
"gross_shares": gross_shares,
"withholding_pct": withholding_pct,
}
if notes is not None:
data["notes"] = notes
run(lambda c: c.create_rsu_grant(data), as_json)
@app.command()
def vest(
grant_id: int = typer.Argument(...),
net_shares: Optional[int] = typer.Option(None, "--net-shares"),
withholding_pct: Optional[float] = typer.Option(None, "--withholding-pct"),
as_json: bool = typer.Option(False, "--json"),
):
"""Vest a grant — creates an rsu_vest stock transaction at the cached price."""
data = {}
if net_shares is not None:
data["net_shares"] = net_shares
if withholding_pct is not None:
data["withholding_pct_override"] = withholding_pct
run(lambda c: c.vest_rsu_grant(grant_id, data), as_json)
@app.command()
def rm(
grant_id: int = typer.Argument(...),
as_json: bool = typer.Option(False, "--json"),
):
"""Delete a pending grant (vested grants cannot be deleted)."""
run(lambda c: c.delete_rsu_grant(grant_id), as_json)
def _schedule_data(account, ticker, grant_date, total_gross_shares, cliff_months,
total_months, frequency_months, withholding_pct, notes):
data = {
"account_id": account,
"ticker": ticker,
"grant_date": grant_date,
"total_gross_shares": total_gross_shares,
"cliff_months": cliff_months,
"total_months": total_months,
"frequency_months": frequency_months,
"withholding_pct": withholding_pct,
}
if notes is not None:
data["notes"] = notes
return data
@schedule_app.command()
def preview(
account: int = typer.Option(..., "--account"),
ticker: str = typer.Option(..., "--ticker"),
grant_date: str = typer.Option(..., "--grant-date", help="YYYY-MM-DD."),
total_gross_shares: float = typer.Option(..., "--total-gross-shares"),
cliff_months: int = typer.Option(12, "--cliff-months"),
total_months: int = typer.Option(36, "--total-months"),
frequency_months: int = typer.Option(3, "--frequency-months"),
withholding_pct: float = typer.Option(0.5, "--withholding-pct"),
notes: Optional[str] = typer.Option(None, "--notes"),
as_json: bool = typer.Option(False, "--json"),
):
"""Compute vesting tranches without persisting anything."""
data = _schedule_data(account, ticker, grant_date, total_gross_shares, cliff_months,
total_months, frequency_months, withholding_pct, notes)
run(lambda c: c.preview_rsu_schedule(data), as_json)
@schedule_app.command()
def generate(
account: int = typer.Option(..., "--account"),
ticker: str = typer.Option(..., "--ticker"),
grant_date: str = typer.Option(..., "--grant-date", help="YYYY-MM-DD."),
total_gross_shares: float = typer.Option(..., "--total-gross-shares"),
cliff_months: int = typer.Option(12, "--cliff-months"),
total_months: int = typer.Option(36, "--total-months"),
frequency_months: int = typer.Option(3, "--frequency-months"),
withholding_pct: float = typer.Option(0.5, "--withholding-pct"),
notes: Optional[str] = typer.Option(None, "--notes"),
as_json: bool = typer.Option(False, "--json"),
):
"""Compute and persist the full vesting schedule as individual grants."""
data = _schedule_data(account, ticker, grant_date, total_gross_shares, cliff_months,
total_months, frequency_months, withholding_pct, notes)
run(lambda c: c.generate_rsu_schedule(data), as_json)
+35
View File
@@ -0,0 +1,35 @@
from typing import Optional
import typer
from fin_cli.output import run
app = typer.Typer(help="Spending analytics.")
@app.command()
def month(
month: str = typer.Argument(..., help="YYYY-MM."),
account: Optional[int] = typer.Option(None, "--account", help="Filter by account id."),
as_json: bool = typer.Option(False, "--json"),
):
"""Monthly spending by category, plus income total."""
run(lambda c: c.get_spending_month(month, account_id=account), as_json)
@app.command()
def summary(
year: Optional[int] = typer.Option(None, "--year", help="Yearly mode."),
start: Optional[str] = typer.Option(None, "--start", help="YYYY-MM-DD (custom mode)."),
end: Optional[str] = typer.Option(None, "--end", help="YYYY-MM-DD (custom mode)."),
as_json: bool = typer.Option(False, "--json"),
):
"""Aggregate spending: --year YYYY, or --start/--end for a custom range."""
if year is not None:
params = {"mode": "yearly", "year": year}
elif start and end:
params = {"mode": "custom", "start": start, "end": end}
else:
typer.secho("Pass either --year, or both --start and --end.", err=True, fg=typer.colors.RED)
raise typer.Exit(1)
run(lambda c: c.get_spending_summary(**params), as_json)
+93
View File
@@ -0,0 +1,93 @@
from typing import Optional
import typer
from fin_cli.output import run
app = typer.Typer(help="Stock transactions (holdings are derived from these).")
@app.command("list")
def list_stock(
account: Optional[int] = typer.Option(None, "--account", help="Filter by account id."),
as_json: bool = typer.Option(False, "--json"),
):
"""List stock transactions."""
run(lambda c: c.list_stock_transactions(account_id=account), as_json)
@app.command()
def add(
account: int = typer.Option(..., "--account", help="Investment/RSU account id."),
ticker: str = typer.Option(..., "--ticker"),
type: str = typer.Option(..., "--type", help="buy, sell or rsu_vest."),
shares: float = typer.Option(..., "--shares"),
price: float = typer.Option(..., "--price", help="Price per share."),
total_cost: float = typer.Option(..., "--total-cost"),
date: str = typer.Option(..., "--date", help="YYYY-MM-DD."),
fee: float = typer.Option(0, "--fee"),
currency: str = typer.Option("USD", "--currency"),
notes: Optional[str] = typer.Option(None, "--notes"),
as_json: bool = typer.Option(False, "--json"),
):
"""Record a stock transaction (no bank leg — see funded-buy for that)."""
data = {
"account_id": account,
"ticker": ticker,
"type": type,
"shares": shares,
"price_per_share": price,
"total_cost": total_cost,
"date": date,
"fee": fee,
"currency": currency,
}
if notes is not None:
data["notes"] = notes
run(lambda c: c.create_stock_transaction(data), as_json)
@app.command("funded-buy")
def funded_buy(
account: int = typer.Option(..., "--account", help="Investment account id."),
funded_by: int = typer.Option(..., "--funded-by", help="Bank account id that pays."),
ticker: str = typer.Option(..., "--ticker"),
shares: float = typer.Option(..., "--shares"),
price: float = typer.Option(..., "--price", help="Price per share."),
total_cost: float = typer.Option(..., "--total-cost"),
date: str = typer.Option(..., "--date", help="YYYY-MM-DD."),
type: str = typer.Option("buy", "--type", help="buy or sell."),
fee: float = typer.Option(0, "--fee"),
currency: str = typer.Option("USD", "--currency"),
exchange_rate: Optional[float] = typer.Option(None, "--exchange-rate"),
notes: Optional[str] = typer.Option(None, "--notes"),
as_json: bool = typer.Option(False, "--json"),
):
"""Buy/sell stock with the matching bank transfer leg created atomically.
This is the right way to buy stock from a bank account."""
data = {
"account_id": account,
"funded_by_account_id": funded_by,
"ticker": ticker,
"type": type,
"shares": shares,
"price_per_share": price,
"total_cost": total_cost,
"date": date,
"fee": fee,
"currency": currency,
}
if exchange_rate is not None:
data["exchange_rate_used"] = exchange_rate
if notes is not None:
data["notes"] = notes
run(lambda c: c.funded_buy(data), as_json)
@app.command()
def rm(
stock_txn_id: int = typer.Argument(...),
as_json: bool = typer.Option(False, "--json"),
):
"""Delete a stock transaction; the holding is recomputed from history."""
run(lambda c: c.delete_stock_transaction(stock_txn_id), as_json)
+104
View File
@@ -0,0 +1,104 @@
from typing import Optional
import typer
from fin_cli.output import run
app = typer.Typer(help="List, add, update and delete transactions.")
TX_TYPES = ["expense", "income", "splitwise_pay", "splitwise_receive"]
@app.command("list")
def list_transactions(
account: Optional[int] = typer.Option(None, "--account", help="Filter by account id."),
category: Optional[int] = typer.Option(None, "--category", help="Filter by category id."),
month: Optional[str] = typer.Option(None, "--month", help="YYYY-MM."),
limit: Optional[int] = typer.Option(None, "--limit", help="Max rows (newest first)."),
as_json: bool = typer.Option(False, "--json"),
):
"""List transactions, newest first."""
run(
lambda c: c.list_transactions(
account_id=account, category_id=category, month=month, limit=limit
),
as_json,
)
@app.command()
def add(
account: int = typer.Option(..., "--account", help="Account id."),
amount: float = typer.Option(..., "--amount", help="Positive for money in, negative for money out."),
date: str = typer.Option(..., "--date", help="YYYY-MM-DD."),
type: str = typer.Option("expense", "--type", help=f"One of: {', '.join(TX_TYPES)}."),
category: Optional[int] = typer.Option(None, "--category"),
description: Optional[str] = typer.Option(None, "--description"),
fee: float = typer.Option(0, "--fee"),
currency: str = typer.Option("EUR", "--currency"),
partner_share: Optional[float] = typer.Option(
None, "--partner-share", help="splitwise_partner_share for splitwise_pay rows."
),
as_json: bool = typer.Option(False, "--json"),
):
"""Add a single transaction. Transfers/settlements are two-leg pairs —
use `fin transfer` / `fin settle`, never single rows."""
if type not in TX_TYPES:
typer.secho(
f"Type must be one of: {', '.join(TX_TYPES)}. "
"Transfers and settlements are two-leg pairs — use `fin transfer` or `fin settle`.",
err=True,
fg=typer.colors.RED,
)
raise typer.Exit(1)
data = {
"account_id": account,
"amount": amount,
"date": date,
"type": type,
"fee": fee,
"currency": currency,
}
if category is not None:
data["category_id"] = category
if description is not None:
data["description"] = description
if partner_share is not None:
data["splitwise_partner_share"] = partner_share
run(lambda c: c.create_transaction(data), as_json)
@app.command()
def update(
transaction_id: int = typer.Argument(...),
amount: Optional[float] = typer.Option(None, "--amount"),
description: Optional[str] = typer.Option(None, "--description"),
category: Optional[int] = typer.Option(None, "--category"),
date: Optional[str] = typer.Option(None, "--date", help="YYYY-MM-DD."),
as_json: bool = typer.Option(False, "--json"),
):
"""Update a transaction (rejected for transfer/settlement legs)."""
data = {
k: v
for k, v in {
"amount": amount,
"description": description,
"category_id": category,
"date": date,
}.items()
if v is not None
}
if not data:
typer.secho("Nothing to update — pass at least one field flag.", err=True, fg=typer.colors.RED)
raise typer.Exit(1)
run(lambda c: c.update_transaction(transaction_id, data), as_json)
@app.command()
def rm(
transaction_id: int = typer.Argument(...),
as_json: bool = typer.Option(False, "--json"),
):
"""Delete a transaction. If it is one leg of a transfer/settlement, the
WHOLE linked group is deleted — both legs, atomically."""
run(lambda c: c.delete_transaction(transaction_id), as_json)
+20
View File
@@ -0,0 +1,20 @@
[project]
name = "fin-cli"
version = "0.1.0"
description = "Command-line client for the fin personal finance API"
requires-python = ">=3.12"
dependencies = [
"typer>=0.12",
"httpx>=0.27",
"rich>=13",
]
[project.scripts]
fin = "fin_cli.main:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["fin_cli"]
+58
View File
@@ -0,0 +1,58 @@
"""End-to-end fixtures: FinClient → real FastAPI app → real crud → SQLite.
The api package is imported straight from api/ (sys.path insertion) with a
throwaway DATABASE_URL set before the import chain reads it. The scheduler
never starts because tests do not run the app's lifespan.
"""
import os
import sys
import tempfile
import types
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT / "api"))
sys.path.insert(0, str(REPO_ROOT / "cli"))
# yfinance is a module-scope import in the price fetcher but no test touches
# it; stub it out so the (heavy) package doesn't need to be installed.
sys.modules.setdefault("yfinance", types.ModuleType("yfinance"))
_db_dir = tempfile.mkdtemp(prefix="fin-cli-tests-")
os.environ["DATABASE_URL"] = f"sqlite:///{_db_dir}/test.db"
from fastapi.testclient import TestClient # noqa: E402
from app.database import Base, engine # noqa: E402
from app.main import app as fastapi_app # noqa: E402
from fin_cli.client import FinClient # noqa: E402
@pytest.fixture(autouse=True)
def clean_db(monkeypatch):
monkeypatch.delenv("FIN_API_TOKEN", raising=False)
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
yield
@pytest.fixture
def make_client():
"""Build a FinClient backed by the in-process app, optionally with a token."""
def _make(token: str | None = None) -> FinClient:
http = TestClient(fastapi_app)
if token:
http.headers["Authorization"] = f"Bearer {token}"
return FinClient(http=http)
return _make
@pytest.fixture
def client(make_client):
return make_client()
+308
View File
@@ -0,0 +1,308 @@
import json
import pytest
from fin_cli.client import FinClientError
def _bank_account(client, name="Checking", initial_balance=None):
data = {"name": name, "type": "bank", "currency": "EUR"}
if initial_balance is not None:
data["initial_balance"] = initial_balance
return client.create_account(data)
def _account(client, name, type_):
return client.create_account({"name": name, "type": type_, "currency": "EUR"})
class TestAuth:
def test_auth_off_allows_requests(self, client):
assert client.list_accounts() == []
def test_health_ok(self, client):
assert client.health() == {"status": "ok"}
def test_auth_on_rejects_missing_token(self, client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
with pytest.raises(FinClientError) as exc:
client.list_accounts()
assert exc.value.status_code == 401
def test_auth_on_rejects_wrong_token(self, make_client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
with pytest.raises(FinClientError) as exc:
make_client(token="wrong").list_accounts()
assert exc.value.status_code == 401
def test_auth_on_accepts_correct_token(self, make_client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
assert make_client(token="sekret").list_accounts() == []
def test_health_bypasses_auth(self, client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
assert client.health() == {"status": "ok"}
def test_bearer_scheme_is_case_insensitive(self, make_client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
http_client = make_client(token="sekret")
http_client._http.headers["Authorization"] = "bearer sekret"
assert http_client.list_accounts() == []
def test_openapi_gated_when_auth_on(self, client, make_client, monkeypatch):
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
with pytest.raises(FinClientError) as exc:
client.openapi_spec()
assert exc.value.status_code == 401
spec = make_client(token="sekret").openapi_spec()
assert "/api/accounts" in spec["paths"]
def test_openapi_open_when_auth_off(self, client):
spec = client.openapi_spec()
assert "/api/accounts" in spec["paths"]
class TestAccounts:
def test_create_and_list(self, client):
created = _bank_account(client, "Giro")
assert created["name"] == "Giro"
assert created["type"] == "bank"
accounts = client.list_accounts()
assert [a["id"] for a in accounts] == [created["id"]]
def test_initial_balance_creates_opening_transaction(self, client):
acct = _bank_account(client, "Giro", initial_balance=250.0)
txns = client.list_transactions(account_id=acct["id"])
assert len(txns) == 1
assert txns[0]["amount"] == 250.0
assert txns[0]["type"] == "income"
class TestTransactions:
def test_add_and_list_with_limit(self, client):
acct = _bank_account(client)
for day in (1, 2, 3):
client.create_transaction(
{
"account_id": acct["id"],
"amount": -10.0 * day,
"date": f"2026-06-0{day}",
"type": "expense",
}
)
all_rows = client.list_transactions(account_id=acct["id"])
assert len(all_rows) == 3
limited = client.list_transactions(account_id=acct["id"], limit=2)
assert len(limited) == 2
# newest first
assert limited[0]["date"] == "2026-06-03"
def test_limit_must_be_positive(self, client):
with pytest.raises(FinClientError) as exc:
client.list_transactions(limit=0)
assert exc.value.status_code == 422
class TestTransfer:
def test_two_legs_created_and_linked(self, client):
a = _bank_account(client, "A", initial_balance=100)
b = _bank_account(client, "B")
legs = client.create_transfer(
{
"from_account_id": a["id"],
"to_account_id": b["id"],
"amount": 40.0,
"date": "2026-06-15",
}
)
assert len(legs) == 2
by_account = {leg["account_id"]: leg for leg in legs}
assert by_account[a["id"]]["amount"] == -40.0
assert by_account[a["id"]]["linked_account_id"] == b["id"]
assert by_account[b["id"]]["amount"] == 40.0
assert by_account[b["id"]]["linked_account_id"] == a["id"]
def test_deleting_one_leg_removes_whole_group(self, client):
a = _bank_account(client, "A")
b = _bank_account(client, "B")
legs = client.create_transfer(
{
"from_account_id": a["id"],
"to_account_id": b["id"],
"amount": 40.0,
"date": "2026-06-15",
}
)
client.delete_transaction(legs[0]["id"])
assert client.list_transactions() == []
def test_editing_one_leg_is_rejected(self, client):
a = _bank_account(client, "A")
b = _bank_account(client, "B")
legs = client.create_transfer(
{
"from_account_id": a["id"],
"to_account_id": b["id"],
"amount": 40.0,
"date": "2026-06-15",
}
)
with pytest.raises(FinClientError) as exc:
client.update_transaction(legs[0]["id"], {"amount": -30.0})
assert exc.value.status_code == 422
class TestSettlement:
def test_bank_to_splitwise_two_legs_linked(self, client):
bank = _bank_account(client, "Giro")
sw = _account(client, "Splitwise", "splitwise")
legs = client.create_settlement(
{
"bank_account_id": bank["id"],
"splitwise_account_id": sw["id"],
"amount": 30.0,
"date": "2026-06-20",
"direction": "bank_to_splitwise",
}
)
assert len(legs) == 2
by_account = {leg["account_id"]: leg for leg in legs}
assert by_account[bank["id"]]["amount"] == -30.0
assert by_account[bank["id"]]["linked_account_id"] == sw["id"]
assert by_account[sw["id"]]["amount"] == 30.0
assert by_account[sw["id"]]["linked_account_id"] == bank["id"]
assert all(leg["type"] == "settlement" for leg in legs)
class TestSplitExpense:
def test_three_rows_and_user_share_spending(self, client):
bank = _bank_account(client, "Giro")
recv = _account(client, "Partner owes me", "receivable")
rows = client.create_split_expense(
{
"bank_account_id": bank["id"],
"receivable_account_id": recv["id"],
"total_amount": 100.0,
"partner_share": 40.0,
"date": "2026-06-21",
}
)
assert len(rows) == 3
expense = [r for r in rows if r["type"] == "expense"]
transfers = [r for r in rows if r["type"] == "transfer"]
assert len(expense) == 1 and len(transfers) == 2
# user's own share on the bank account
assert expense[0]["amount"] == -60.0
by_account_amounts = sorted(t["amount"] for t in transfers)
assert by_account_amounts == [-40.0, 40.0]
# spending counts only the user's share; the transfer legs are excluded
spending = client.get_spending_month("2026-06")
assert spending["total"] == pytest.approx(60.0)
class TestSplitwisePaid:
def test_spending_counts_net_share(self, client):
bank = _bank_account(client, "Giro")
sw = _account(client, "Splitwise", "splitwise")
legs = client.create_splitwise_i_paid(
{
"bank_account_id": bank["id"],
"splitwise_account_id": sw["id"],
"total_amount": 100.0,
"partner_share": 45.0,
"date": "2026-06-22",
}
)
by_account = {leg["account_id"]: leg for leg in legs}
assert by_account[bank["id"]]["amount"] == -100.0
assert by_account[bank["id"]]["splitwise_partner_share"] == 45.0
assert by_account[sw["id"]]["amount"] == 45.0
# CLAUDE.md invariant 6: spending = amount + splitwise_partner_share
spending = client.get_spending_month("2026-06")
assert spending["total"] == pytest.approx(55.0)
class TestNetWorth:
def test_current_reflects_bank_balance(self, client):
_bank_account(client, "Giro", initial_balance=100.0)
nw = client.get_net_worth()
assert nw["total_eur"] == pytest.approx(100.0)
assert len(nw["accounts"]) == 1
def test_fee_reduces_balance(self, client):
# CLAUDE.md invariant 5: balance = SUM(amount - fee)
acct = _bank_account(client, "Giro", initial_balance=100.0)
client.create_transaction(
{
"account_id": acct["id"],
"amount": -50.0,
"fee": 2.0,
"date": "2026-06-10",
"type": "expense",
}
)
nw = client.get_net_worth()
assert nw["total_eur"] == pytest.approx(48.0)
class TestSpending:
def test_monthly_totals(self, client):
acct = _bank_account(client)
cat = client.create_category(
{"name": "Dining", "color": "#f97316", "icon": "🍽️"}
)
client.create_transaction(
{
"account_id": acct["id"],
"amount": -50.0,
"date": "2026-06-10",
"type": "expense",
"category_id": cat["id"],
}
)
spending = client.get_spending_month("2026-06")
assert spending["total"] == pytest.approx(50.0)
cats = {c["category_name"]: c for c in spending["categories"]}
assert cats["Dining"]["total"] == pytest.approx(50.0)
class TestCliWiring:
"""Smoke test that the typer app is wired to the client correctly."""
def test_accounts_list_json(self, client, monkeypatch):
from typer.testing import CliRunner
import fin_cli.output
from fin_cli.main import app
_bank_account(client, "Giro")
monkeypatch.setattr(fin_cli.output, "get_client", lambda: client)
result = CliRunner().invoke(app, ["accounts", "list", "--json"])
assert result.exit_code == 0, result.output
rows = json.loads(result.output)
assert rows[0]["name"] == "Giro"
def test_error_exits_nonzero(self, client, monkeypatch):
from typer.testing import CliRunner
import fin_cli.output
from fin_cli.main import app
monkeypatch.setattr(fin_cli.output, "get_client", lambda: client)
result = CliRunner().invoke(app, ["tx", "rm", "99999"])
assert result.exit_code == 1
def test_tx_add_refuses_transfer_type(self, client, monkeypatch):
from typer.testing import CliRunner
import fin_cli.output
from fin_cli.main import app
monkeypatch.setattr(fin_cli.output, "get_client", lambda: client)
result = CliRunner().invoke(
app,
["tx", "add", "--account", "1", "--amount", "-5",
"--date", "2026-06-01", "--type", "transfer"],
)
assert result.exit_code == 1
assert client.list_transactions() == []