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:
co-authored by
Claude Sonnet 4.6
parent
e36d719cbe
commit
082b6081b7
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""fin-cli — command-line client for the fin personal finance API."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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() == []
|
||||
Reference in New Issue
Block a user