From ad452045be4b42dc5550bd5e6f3a20980d5d6877 Mon Sep 17 00:00:00 2001 From: kai Date: Wed, 9 Sep 2026 09:47:56 +0200 Subject: [PATCH] Add authenticated finance REST API --- .env.example | 1 + README.md | 109 +++++++++++++++++ app/api/auth.py | 16 +++ app/api/routes.py | 155 ++++++++++++++++++++++++ app/api/schemas.py | 133 +++++++++++++++++++++ app/database.py | 17 ++- app/main.py | 4 +- app/models.py | 7 +- app/routes/income.py | 13 +- app/services/asset_service.py | 59 +++++++++ app/services/income_service.py | 77 +++++++----- docker-compose.yml | 2 + tests/test_api.py | 211 +++++++++++++++++++++++++++++++++ 13 files changed, 760 insertions(+), 44 deletions(-) create mode 100644 .env.example create mode 100644 app/api/auth.py create mode 100644 app/api/routes.py create mode 100644 app/api/schemas.py create mode 100644 app/services/asset_service.py create mode 100644 tests/test_api.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a65aefe --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +FINANCE_API_TOKEN=change-me diff --git a/README.md b/README.md index 807620f..4cceb29 100644 --- a/README.md +++ b/README.md @@ -135,3 +135,112 @@ Die lokale Datenbank wird **nicht mit Git übertragen**. Für die Erstübernahme Nur für das private LAN, ohne Benutzerverwaltung. Validierung, SQL-Parameterbindung, Jinja-Autoescaping und Prüfung fremder Browser-Formularursprünge sind enthalten. Chart.js wird fest versioniert vom CDN geladen; ohne Internet funktionieren Buchungen, Kennzahlen und Tabellen weiter. Die Diagramme benötigen Zugang zum CDN. Implementierungsreferenzen: [FastAPI Templates](https://fastapi.tiangolo.com/advanced/templates/) und [Chart.js Integration](https://www.chartjs.org/docs/latest/getting-started/integration.html). + +## REST-API aktivieren + +Die API läuft im bestehenden FastAPI-Prozess unter **`/api/v1`** und verwendet dieselben Services und dieselbe SQLite-Datenbank wie die Weboberfläche. Es gibt keine neue Datenhaltung und keine zusätzlichen Laufzeit-Abhängigkeiten. + +1. Ein langes zufälliges Token erzeugen: + + ```bash + python -c 'import secrets; print(secrets.token_urlsafe(32))' + ``` + +2. Im Projektverzeichnis eine `.env` anlegen bzw. die vorhandene Datei ergänzen: + + ```dotenv + FINANCE_API_TOKEN= + ``` + + `.env.example` enthält nur den Platzhalter `change-me`. Diesen durch das erzeugte Token ersetzen; `change-me` aktiviert die API ausdrücklich nicht. `.env` bleibt in `.gitignore` und `.dockerignore`. Ein echtes Token gehört niemals ins Repository. + +3. Den Container nach einer Tokenänderung neu erstellen: + + ```bash + docker compose up -d --no-build --force-recreate + ``` + +Compose liest `.env` automatisch und reicht `FINANCE_API_TOKEN` weiter. Bei direktem Uvicorn-Start muss die Variable in der Prozessumgebung gesetzt sein. Ohne Token, bei leerem Token oder beim Beispiel-Platzhalter antwortet die API mit **503**. Weboberfläche und `/health` funktionieren weiterhin. Ein fehlender/falscher Bearer-Header bei aktivierter API führt zu **401** mit `WWW-Authenticate: Bearer`. Der Vergleich erfolgt mit `secrets.compare_digest`. Tokens werden nicht protokolliert. CORS bleibt deaktiviert. + +Für die folgenden curl-Beispiele muss `FINANCE_API_TOKEN` auch in der aufrufenden Shell gesetzt sein. Im eigenen Projektverzeichnis kann die selbst angelegte `.env` geladen werden: + +```bash +set -a +. ./.env +set +a + +curl \ + -H "Authorization: Bearer $FINANCE_API_TOKEN" \ + http://pinguAurora:8081/api/v1/assets +``` + +### Endpunkte + +Alle folgenden Pfade beginnen mit `/api/v1` und erfordern den Bearer-Header, auch `/meta`. + +| Methode | Pfad | Verhalten | +| --- | --- | --- | +| GET | `/assets` | Alle Positionen, optional `active=true/false` | +| GET | `/assets/{id}` | Einzelne Position, sonst 404 | +| POST | `/assets` | Position erstellen, 201; normalisierte Duplikate 409 | +| PATCH | `/assets/{id}` | Nur übergebene Felder ändern | +| DELETE | `/assets/{id}` | Position deaktivieren (`active=false`), 204; Historie bleibt erhalten | +| GET | `/income` | Historie, neueste zuerst; Filter und Pagination | +| GET | `/income/{id}` | Einzelne Buchung, sonst 404 | +| POST | `/income` | Zahlung erstellen und zurückgeben, 201 | +| PATCH | `/income/{id}` | Teiländerung, aktualisierte Zahlung zurückgeben | +| DELETE | `/income/{id}` | Buchung löschen, 204 ohne Inhalt | +| GET | `/stats/summary` | Dieselben acht Kennzahlen wie im Dashboard | +| GET | `/stats/monthly` | Monatswerte je Jahr, optional `year` | +| GET | `/stats/by-asset` | Betrag und Anteil je Position, alle Jahre | +| GET | `/stats/by-category` | Betrag und Anteil für alle vier Kategorien | +| GET | `/meta` | Name, API-Version und Datenbankstatus ohne Systemdetails | + +`GET /income` unterstützt `year`, `month`, `asset_id`, `category`, `received`, `expected`, `limit` (Standard 100, maximal 1000) und `offset` (Standard 0). Sortierung: Datum absteigend, bei gleichem Datum ID absteigend. Listen werden als JSON-Arrays geliefert. `asset` in Buchungen und Anteilen ist der normalisierte Positionsname; `asset_id` identifiziert die Position eindeutig. + +Geldbeträge werden im JSON **ausschließlich als Strings in Euro** mit zwei Nachkommastellen ausgegeben, z. B. `"0.04"`. Auch Eingaben müssen Dezimalstrings sein (`"0.04"` oder `"0,04"`); JSON-Fließkommazahlen werden abgelehnt. Intern bleibt es bei Decimal und Integer-Cent. Prozentwerte sind ebenfalls Strings mit zwei Nachkommastellen; bei Nenner 0 ist der Wert `null`. Ein Anteil wird relativ zur tatsächlichen Gesamtsumme berechnet, inklusive negativer Korrekturen. + +`/stats/monthly` liefert ein Array aus `{year, months: [{month, amount}], total}` mit zwölf Monaten pro Jahr. Ein explizit angefragtes Jahr ohne Buchungen liefert Nullbeträge. Alle Statistik-Endpunkte zählen ausschließlich `received=true`; `expected=true, received=false` erhöht keine tatsächliche Einnahme. Der Jahresvergleich bleibt aktuelles Jahr gegen gesamtes Vorjahr. `/stats/by-category` trennt `dividend` und `distribution`; das Dashboard-Diagramm fasst sie zur Anzeige zusammen. + +Unbekannte IDs liefern 404, doppelte Positionsnamen 409 und ungültige Felder/Referenzen 422. `PATCH` lässt fehlende Felder unverändert; `note: null` bzw. `ticker: null` leert optionale Felder. Andere Felder dürfen nicht `null` sein. Historische Buchungen deaktivierter Positionen bleiben lesbar und bearbeitbar; neue Buchungen für inaktive Positionen werden abgelehnt. Reaktivierung erfolgt über `PATCH /assets/{id}` mit `{"active":true}`. Datenbank-Locks führen nach der bestehenden Wartezeit zu 503 mit Wiederholungshinweis; interne Fehler zu 500 ohne Stacktrace oder interne Details. + +### Zahlung per API erstellen + +Zuerst die passende `asset_id` über `/assets` ermitteln; die ID im folgenden Beispiel ersetzen: + +```bash +curl -X POST \ + -H "Authorization: Bearer $FINANCE_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "date":"2026-09-02", + "asset_id":1, + "category":"dividend", + "amount":"0.04", + "note":"Test", + "expected":false, + "received":true + }' \ + http://pinguAurora:8081/api/v1/income +``` + +POST legt jeweils eine echte neue Zahlung an; die Dublettenerkennung des Excel-Imports gilt nicht für manuelle/API-Buchungen. + +Swagger: **http://pinguAurora:8081/docs**. Unter **Authorize** nur das Token eingeben; Swagger ergänzt `Bearer`. OpenAPI: `/openapi.json`. Die Tags heißen Assets, Income und Stats. Die Dokumentation ist lesbar, die dokumentierten API-Aufrufe erfordern Authentifizierung. + +### API-Deployment auf pinguAurora + +Einmalig `.env` mit einem echten Token auf dem Raspberry anlegen. Dann weiterhin der bestehende Weg ohne Buildx: + +```bash +git pull +DOCKER_BUILDKIT=0 docker build \ + -t finance-dashboard-finance-dashboard:latest \ + . +docker compose up -d --no-build +curl --fail http://127.0.0.1:8081/health +``` + +`./deploy.sh` bleibt unverändert nutzbar. Die vorhandene `/data/finance.db` wird weiterverwendet und weder gelöscht noch überschrieben. API-Tests setzen ein zufälliges Testtoken in der Testumgebung und verwenden ausschließlich temporäre Datenbanken. + +Technische Referenzen: [FastAPI HTTPBearer](https://fastapi.tiangolo.com/reference/security/) und [Pydantic Serialization](https://docs.pydantic.dev/latest/concepts/serialization/). diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..249e5e2 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,16 @@ +import os +import secrets +from typing import Annotated +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +bearer = HTTPBearer(auto_error=False, scheme_name='FinanceAPIToken', + description='Bearer-Token aus FINANCE_API_TOKEN. Kein Standard-Token.') + + +def require_token(credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]): + token = os.environ.get('FINANCE_API_TOKEN', '') + if not token.strip() or token == 'change-me': + raise HTTPException(503, 'API nicht konfiguriert. FINANCE_API_TOKEN muss gesetzt werden.') + if credentials is None or not secrets.compare_digest(credentials.credentials.encode(), token.encode()): + raise HTTPException(401, 'Fehlendes oder ungültiges API-Token.', headers={'WWW-Authenticate': 'Bearer'}) diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..77cf22d --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,155 @@ +"""Authenticated REST adapters around the same services used by Jinja pages.""" +from decimal import Decimal, ROUND_HALF_UP +import sqlite3 +from typing import Annotated +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute +from database import connect +from models import CATEGORIES, decimal_string +from services import asset_service, income_service +from api.auth import require_token +from api.schemas import ( + AssetCreate, AssetPatch, AssetResponse, AssetShareResponse, Category, + CategoryShareResponse, IncomeCreate, IncomePatch, IncomeResponse, + MetaResponse, MonthlyResponse, SummaryResponse, +) + + +class SafeAPIRoute(APIRoute): + def get_route_handler(self): + original = super().get_route_handler() + + async def safe_handler(request): + try: + return await original(request) + except HTTPException: + raise + except RequestValidationError as error: + # Never echo raw request bodies, credentials or internal exception context. + details = [{'loc': e['loc'], 'msg': e['msg'], 'type': e['type']} for e in error.errors()] + return JSONResponse({'detail': details}, status_code=422) + except ValueError: + return JSONResponse({'detail': 'Ungültige Werte. Bitte Felder und Position prüfen.'}, status_code=422) + except sqlite3.IntegrityError: + return JSONResponse({'detail': 'Änderung steht im Konflikt mit vorhandenen Daten.'}, status_code=409) + except sqlite3.OperationalError: + return JSONResponse({'detail': 'Datenbank vorübergehend nicht verfügbar.'}, status_code=503, headers={'Retry-After': '5'}) + except Exception: + return JSONResponse({'detail': 'Interner Fehler. Anfrage konnte nicht verarbeitet werden.'}, status_code=500) + return safe_handler + + +router = APIRouter(prefix='/api/v1', dependencies=[Depends(require_token)], route_class=SafeAPIRoute, + responses={401: {'description': 'Token fehlt oder ist ungültig'}, + 503: {'description': 'API nicht konfiguriert oder Datenbank nicht verfügbar'}}) + + +def income_response(row): + return IncomeResponse(**{**dict(row), 'asset': row['name'], 'amount': decimal_string(row['amount'])}) + + +def percentage(amount, total): + if total == 0: + return None + return format((Decimal(amount) * 100 / Decimal(total)).quantize(Decimal('.01'), rounding=ROUND_HALF_UP), '.2f') + + +@router.get('/assets', response_model=list[AssetResponse], tags=['Assets']) +def assets(active: bool | None = None): + return [AssetResponse(**row) for row in asset_service.list_assets(active)] + + +@router.get('/assets/{asset_id}', response_model=AssetResponse, tags=['Assets']) +def asset(asset_id: int): + return AssetResponse(**asset_service.get_asset(asset_id)) + + +@router.post('/assets', response_model=AssetResponse, status_code=201, tags=['Assets']) +def create_asset(data: AssetCreate): + return AssetResponse(**asset_service.create_asset(**data.model_dump())) + + +@router.patch('/assets/{asset_id}', response_model=AssetResponse, tags=['Assets']) +def update_asset(asset_id: int, data: AssetPatch): + return AssetResponse(**asset_service.update_asset(asset_id, data.model_dump(exclude_unset=True))) + + +@router.delete('/assets/{asset_id}', status_code=204, tags=['Assets']) +def delete_asset(asset_id: int): + asset_service.deactivate_asset(asset_id) + return Response(status_code=204) + + +@router.get('/income', response_model=list[IncomeResponse], tags=['Income']) +def income(year: Annotated[int | None, Query(ge=1, le=9999)] = None, + month: Annotated[int | None, Query(ge=1, le=12)] = None, + asset_id: Annotated[int | None, Query(ge=1, le=9223372036854775807)] = None, + category: Category | None = None, received: bool | None = None, expected: bool | None = None, + limit: Annotated[int, Query(ge=1, le=1000)] = 100, + offset: Annotated[int, Query(ge=0, le=9223372036854775807)] = 0): + return [income_response(row) for row in income_service.list_entries( + year, month, asset_id, category, limit, offset, received, expected)] + + +@router.get('/income/{entry_id}', response_model=IncomeResponse, tags=['Income']) +def income_entry(entry_id: int): + return income_response(income_service.get_entry(entry_id)) + + +@router.post('/income', response_model=IncomeResponse, status_code=201, tags=['Income']) +def create_income(data: IncomeCreate): + return income_response(income_service.write_entry(data.model_dump())) + + +@router.patch('/income/{entry_id}', response_model=IncomeResponse, tags=['Income']) +def update_income(entry_id: int, data: IncomePatch): + return income_response(income_service.write_entry(data.model_dump(exclude_unset=True), entry_id, partial=True)) + + +@router.delete('/income/{entry_id}', status_code=204, tags=['Income']) +def delete_income(entry_id: int): + income_service.delete_entry(entry_id) + return Response(status_code=204) + + +@router.get('/stats/summary', response_model=SummaryResponse, tags=['Stats']) +def summary(): + stats = income_service.dashboard() + return SummaryResponse( + current_month=decimal_string(stats['month']), current_month_previous_year=decimal_string(stats['prior_month']), + current_month_yoy_percent=None if stats['month_change'] is None else format(stats['month_change'], '.2f'), + current_year=decimal_string(stats['year']), previous_year=decimal_string(stats['prior_year']), + current_year_yoy_percent=None if stats['year_change'] is None else format(stats['year_change'], '.2f'), + all_time=decimal_string(stats['all_time']), current_year_payment_count=stats['count']) + + +@router.get('/stats/monthly', response_model=list[MonthlyResponse], tags=['Stats']) +def monthly(year: Annotated[int | None, Query(ge=1, le=9999)] = None): + stats = income_service.dashboard() + years = [year] if year is not None else stats['years'] + return [MonthlyResponse(year=y, months=[{'month': m+1, 'amount': decimal_string(amount)} + for m, amount in enumerate(stats['monthly'].get(y, [0]*12))], + total=decimal_string(stats['totals'].get(y, 0))) for y in years] + + +@router.get('/stats/by-asset', response_model=list[AssetShareResponse], tags=['Stats']) +def by_asset(): + stats = income_service.dashboard() + return [AssetShareResponse(asset_id=row['asset_id'], asset=row['name'], amount=decimal_string(row['amount']), + percentage=percentage(row['amount'], stats['all_time'])) for row in stats['shares']] + + +@router.get('/stats/by-category', response_model=list[CategoryShareResponse], tags=['Stats']) +def by_category(): + stats = income_service.dashboard() + return [CategoryShareResponse(category=category, amount=decimal_string(stats['categories'].get(category, 0)), + percentage=percentage(stats['categories'].get(category, 0), stats['all_time'])) for category in CATEGORIES] + + +@router.get('/meta', response_model=MetaResponse, tags=['Stats']) +def meta(): + with connect() as db: + db.execute('SELECT COUNT(*) FROM assets').fetchone() + return MetaResponse() diff --git a/app/api/schemas.py b/app/api/schemas.py new file mode 100644 index 0000000..269163f --- /dev/null +++ b/app/api/schemas.py @@ -0,0 +1,133 @@ +"""Transport schemas only; business validation lives in the shared services.""" +from decimal import Decimal +from typing import Annotated, Literal +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StrictBool, model_validator +from models import cents, valid_date + +AssetType = Literal['stock', 'etf', 'bond', 'crypto', 'interest', 'other'] +Category = Literal['dividend', 'interest', 'distribution', 'other'] +Identifier = Annotated[int, Field(strict=True, ge=1, le=9223372036854775807)] +MoneyString = Annotated[str, Field(pattern=r'^-?\d+\.\d{2}$', examples=['0.04'])] + + +def parse_amount(value): + if not isinstance(value, (str, Decimal)): + raise ValueError('Betrag als Dezimalstring senden, zum Beispiel "0.04".') + return Decimal(cents(value)) / 100 + + +Amount = Annotated[Decimal, BeforeValidator(parse_amount, json_schema_input_type=str)] +Day = Annotated[str, BeforeValidator(valid_date)] + + +class RequestModel(BaseModel): + model_config = ConfigDict(extra='forbid') + + +class PatchModel(RequestModel): + @model_validator(mode='before') + @classmethod + def reject_required_nulls(cls, data): + if isinstance(data, dict): + for name, value in data.items(): + if value is None and name not in {'note', 'ticker'}: + raise ValueError('Nur Notiz und Ticker dürfen null sein.') + return data + + +class AssetCreate(RequestModel): + name: str = Field(min_length=1, max_length=150) + ticker: str | None = Field(default=None, max_length=30) + asset_type: AssetType + active: StrictBool = True + + +class AssetPatch(PatchModel): + name: str | None = Field(default=None, min_length=1, max_length=150) + ticker: str | None = Field(default=None, max_length=30) + asset_type: AssetType | None = None + active: StrictBool | None = None + + +class AssetResponse(BaseModel): + id: int + name: str + ticker: str | None + asset_type: AssetType + active: bool + created_at: str + + +class IncomeCreate(RequestModel): + date: Day + asset_id: Identifier + category: Category + amount: Amount + note: str | None = Field(default=None, max_length=2000) + expected: StrictBool = False + received: StrictBool = True + + +class IncomePatch(PatchModel): + date: Day | None = None + asset_id: Identifier | None = None + category: Category | None = None + amount: Amount | None = None + note: str | None = Field(default=None, max_length=2000) + expected: StrictBool | None = None + received: StrictBool | None = None + + +class IncomeResponse(BaseModel): + id: int + date: str + asset: str = Field(description='Normalisierter Positionsname') + asset_id: int + category: Category + amount: MoneyString + note: str | None + expected: bool + received: bool + created_at: str + updated_at: str + + +class SummaryResponse(BaseModel): + current_month: MoneyString + current_month_previous_year: MoneyString + current_month_yoy_percent: MoneyString | None + current_year: MoneyString + previous_year: MoneyString + current_year_yoy_percent: MoneyString | None + all_time: MoneyString + current_year_payment_count: int + + +class MonthResponse(BaseModel): + month: int + amount: MoneyString + + +class MonthlyResponse(BaseModel): + year: int + months: list[MonthResponse] + total: MoneyString + + +class AssetShareResponse(BaseModel): + asset_id: int + asset: str + amount: MoneyString + percentage: MoneyString | None + + +class CategoryShareResponse(BaseModel): + category: Category + amount: MoneyString + percentage: MoneyString | None + + +class MetaResponse(BaseModel): + name: str = 'Finance Dashboard' + api_version: str = 'v1' + database: Literal['ok'] = 'ok' diff --git a/app/database.py b/app/database.py index 0bb4a2c..8f88835 100644 --- a/app/database.py +++ b/app/database.py @@ -23,13 +23,18 @@ def connect(path=None): db.close() -def ensure_asset(db, name, asset_type='other', ticker=None): +def validate_asset(name, asset_type='other', ticker=None): name = canonical_name(name) if asset_type not in ASSET_TYPES: raise ValueError('Ungültiger Positionstyp.') ticker = (ticker or '').strip() if len(ticker) > 30: raise ValueError('Ticker darf maximal 30 Zeichen enthalten.') + return name, ticker or None + + +def ensure_asset(db, name, asset_type='other', ticker=None): + name, ticker = validate_asset(name, asset_type, ticker) db.execute('INSERT INTO assets (name, normalized_name, ticker, asset_type) VALUES (?, ?, ?, ?) ON CONFLICT(normalized_name) DO NOTHING', (name, name_key(name), ticker or None, asset_type)) return db.execute('SELECT id FROM assets WHERE normalized_name = ?', (name_key(name),)).fetchone()['id'] @@ -39,6 +44,7 @@ def initialize(path=None): target = Path(path or db_path()) target.parent.mkdir(parents=True, exist_ok=True) with connect(target) as db: + first_setup = db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='assets'").fetchone() is None db.execute('PRAGMA journal_mode = WAL') db.executescript(''' CREATE TABLE IF NOT EXISTS assets ( @@ -67,7 +73,8 @@ def initialize(path=None): ); PRAGMA user_version = 1; ''') - for name, kind in [('AGNC','stock'), ('Main Street Capital','stock'), ('Capital Southwest','stock'), - ('Ares Capital','stock'), ('Realty Income','stock'), ('Enbridge','stock'), - ('Bayer','stock'), ('STOXX Global Select Dividend 100','etf'), ('airBaltic','bond')]: - ensure_asset(db, name, kind) + if first_setup: + for name, kind in [('AGNC','stock'), ('Main Street Capital','stock'), ('Capital Southwest','stock'), + ('Ares Capital','stock'), ('Realty Income','stock'), ('Enbridge','stock'), + ('Bayer','stock'), ('STOXX Global Select Dividend 100','etf'), ('airBaltic','bond')]: + ensure_asset(db, name, kind) diff --git a/app/main.py b/app/main.py index c41781d..1518625 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from database import initialize from routes import dashboard, income, export +from api.routes import router as api_router @asynccontextmanager @@ -20,11 +21,12 @@ app.mount('/static', StaticFiles(directory=Path(__file__).parent / 'static'), na app.include_router(dashboard.router) app.include_router(income.router) app.include_router(export.router) +app.include_router(api_router) @app.middleware('http') async def protect_forms(request: Request, call_next): - if request.method == 'POST': + if request.method == 'POST' and not request.url.path.startswith('/api/v1/'): origin = request.headers.get('origin') if request.headers.get('sec-fetch-site') == 'cross-site' or (origin and urlsplit(origin).netloc != request.headers.get('host')): return HTMLResponse('Fremder Formularursprung ist nicht erlaubt.', status_code=403) diff --git a/app/models.py b/app/models.py index b7358a3..6bca53a 100644 --- a/app/models.py +++ b/app/models.py @@ -64,6 +64,11 @@ ALIASES = { def canonical_name(value): name = ' '.join(str(value).split()) name = re.sub(r'^(dividende[n]?|ausschüttung)\s+', '', name, flags=re.I) - if not name or len(name) > 150: + if not name or not name_key(name) or len(name) > 150: raise ValueError('Positionsname muss zwischen 1 und 150 Zeichen lang sein.') return ALIASES.get(name_key(name), name) + + +def decimal_string(amount): + """Represent integer cents in JSON without a binary float conversion.""" + return f'{Decimal(amount) / 100:.2f}' diff --git a/app/routes/income.py b/app/routes/income.py index 07bf368..5e2227d 100644 --- a/app/routes/income.py +++ b/app/routes/income.py @@ -3,9 +3,9 @@ from decimal import Decimal from typing import Annotated from fastapi import APIRouter, Form, HTTPException, Query, Request from fastapi.responses import RedirectResponse -from database import connect, ensure_asset +from services.asset_service import create_asset as create_asset_record from models import CATEGORIES -from services.income_service import assets, available_years, get_entry, list_entries, save_entry +from services.income_service import assets, available_years, get_entry, list_entries, save_entry, delete_entry from views import render router = APIRouter() @@ -68,11 +68,7 @@ def save(request: Request, date: Annotated[str, Form()] = '', asset_id: Annotate @router.post('/income/{entry_id}/delete') def delete(entry_id: int): - if not 1 <= entry_id <= 9223372036854775807: - raise HTTPException(404, 'Zahlung nicht gefunden.') - with connect() as db: - if db.execute('DELETE FROM income_entries WHERE id = ?', (entry_id,)).rowcount == 0: - raise HTTPException(404, 'Zahlung nicht gefunden.') + delete_entry(entry_id) return RedirectResponse('/?message=deleted', status_code=303) @@ -84,8 +80,7 @@ def new_asset(request: Request): @router.post('/assets/new') def create_asset(request: Request, name: Annotated[str, Form()], asset_type: Annotated[str, Form()], ticker: Annotated[str, Form()] = ''): try: - with connect() as db: - asset_id = ensure_asset(db, name, asset_type, ticker) + asset_id = create_asset_record(name, asset_type, ticker, reuse=True)['id'] except ValueError as error: return render(request, 'asset_form.html', {'data': dict(name=name, asset_type=asset_type, ticker=ticker), 'error': str(error)}, 422) return RedirectResponse(f'/income/new?asset_id={asset_id}', status_code=303) diff --git a/app/services/asset_service.py b/app/services/asset_service.py new file mode 100644 index 0000000..bff848c --- /dev/null +++ b/app/services/asset_service.py @@ -0,0 +1,59 @@ +"""Asset operations shared by web forms and the REST API.""" +from fastapi import HTTPException +from database import connect, ensure_asset, validate_asset +from models import name_key + + +def list_assets(active=None): + with connect() as db: + if active is None: + rows = db.execute('SELECT * FROM assets ORDER BY name COLLATE NOCASE').fetchall() + else: + rows = db.execute('SELECT * FROM assets WHERE active=? ORDER BY name COLLATE NOCASE', (active,)).fetchall() + return [dict(row) for row in rows] + + +def _get_asset(db, asset_id): + if not 1 <= asset_id <= 9223372036854775807: + raise HTTPException(404, 'Position nicht gefunden.') + row = db.execute('SELECT * FROM assets WHERE id=?', (asset_id,)).fetchone() + if row is None: + raise HTTPException(404, 'Position nicht gefunden.') + return dict(row) + + +def get_asset(asset_id): + with connect() as db: + return _get_asset(db, asset_id) + + +def create_asset(name, asset_type='other', ticker=None, active=True, reuse=False): + name, ticker = validate_asset(name, asset_type, ticker) + with connect() as db: + db.execute('BEGIN IMMEDIATE') + existing = db.execute('SELECT * FROM assets WHERE normalized_name=?', (name_key(name),)).fetchone() + if existing: + if reuse: + return dict(existing) + raise HTTPException(409, 'Diese Position existiert bereits.') + asset_id = ensure_asset(db, name, asset_type, ticker) + db.execute('UPDATE assets SET active=? WHERE id=?', (bool(active), asset_id)) + return _get_asset(db, asset_id) + + +def update_asset(asset_id, changes): + with connect() as db: + db.execute('BEGIN IMMEDIATE') + data = _get_asset(db, asset_id) + data.update(changes) + name, ticker = validate_asset(data['name'], data['asset_type'], data['ticker']) + if db.execute('SELECT 1 FROM assets WHERE normalized_name=? AND id<>?', (name_key(name), asset_id)).fetchone(): + raise HTTPException(409, 'Diese Position existiert bereits.') + db.execute('UPDATE assets SET name=?, normalized_name=?, ticker=?, asset_type=?, active=? WHERE id=?', + (name, name_key(name), ticker, data['asset_type'], bool(data['active']), asset_id)) + return _get_asset(db, asset_id) + + +def deactivate_asset(asset_id): + # Preserve references and history even when an asset has no payments yet. + return update_asset(asset_id, {'active': False}) diff --git a/app/services/income_service.py b/app/services/income_service.py index aaecab0..fd02b92 100644 --- a/app/services/income_service.py +++ b/app/services/income_service.py @@ -1,27 +1,25 @@ from datetime import date from fastapi import HTTPException from database import connect -from models import CATEGORIES, MONTHS, cents, valid_date, percent +from models import CATEGORIES, MONTHS, cents, valid_date, percent, decimal_string +from services.asset_service import list_assets as assets -def assets(): - with connect() as db: - return db.execute('SELECT * FROM assets ORDER BY name COLLATE NOCASE').fetchall() - - -def get_entry(entry_id): +def _get_entry(db, entry_id): if not 1 <= entry_id <= 9223372036854775807: raise HTTPException(404, 'Zahlung nicht gefunden.') - with connect() as db: - row = db.execute('SELECT * FROM income_entries WHERE id = ?', (entry_id,)).fetchone() + row = db.execute('SELECT i.*, a.name FROM income_entries i JOIN assets a ON a.id=i.asset_id WHERE i.id=?', (entry_id,)).fetchone() if row is None: raise HTTPException(404, 'Zahlung nicht gefunden.') return dict(row) -def save_entry(data, entry_id=None): - if entry_id is not None and not 1 <= entry_id <= 9223372036854775807: - raise HTTPException(404, 'Zahlung nicht gefunden.') +def get_entry(entry_id): + with connect() as db: + return _get_entry(db, entry_id) + + +def _validated_values(data, db, existing): day = valid_date(data.get('date', '')) amount = cents(data.get('amount', '')) category = data.get('category', '') @@ -33,32 +31,54 @@ def save_entry(data, entry_id=None): raise ValueError except (TypeError, ValueError): raise ValueError('Bitte eine Position auswählen.') from None - note = data.get('note', '').strip() + note = (data.get('note') or '').strip() if len(note) > 2000: raise ValueError('Notiz darf maximal 2000 Zeichen enthalten.') - expected, received = int(data.get('expected') == '1'), int(data.get('received') == '1') + expected = int(data.get('expected') in (True, '1')) + received = int(data.get('received') in (True, '1')) + asset = db.execute('SELECT * FROM assets WHERE id=?', (asset_id,)).fetchone() + if asset is None or (not asset['active'] and (existing is None or existing['asset_id'] != asset_id)): + raise ValueError('Diese Position ist nicht mehr verfügbar.') + return day, asset_id, category, amount, note or None, expected, received + + +def write_entry(data, entry_id=None, partial=False): + """Validate and write atomically; PATCH merges inside the write transaction.""" with connect() as db: db.execute('BEGIN IMMEDIATE') - existing = db.execute('SELECT * FROM income_entries WHERE id = ?', (entry_id,)).fetchone() if entry_id else None - if entry_id and existing is None: - raise HTTPException(404, 'Zahlung nicht gefunden.') - asset = db.execute('SELECT * FROM assets WHERE id = ?', (asset_id,)).fetchone() - if asset is None or (not asset['active'] and (existing is None or existing['asset_id'] != asset_id)): - raise ValueError('Diese Position ist nicht mehr verfügbar.') - values = (day, asset_id, category, amount, note or None, expected, received) - if entry_id: + existing = _get_entry(db, entry_id) if entry_id is not None else None + if partial: + merged = dict(existing) + merged['amount'] = decimal_string(existing['amount']) + merged.update(data) + data = merged + values = _validated_values(data, db, existing) + if entry_id is not None: db.execute("UPDATE income_entries SET date=?, asset_id=?, category=?, amount=?, note=?, expected=?, received=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?", (*values, entry_id)) else: entry_id = db.execute('INSERT INTO income_entries (date,asset_id,category,amount,note,expected,received) VALUES (?,?,?,?,?,?,?)', values).lastrowid - return entry_id + return _get_entry(db, entry_id) -def list_entries(year=None, month=None, asset_id=None, category=None, limit=None, offset=0): +def save_entry(data, entry_id=None): + """Existing web/import-facing return contract.""" + return write_entry(data, entry_id)['id'] + + +def delete_entry(entry_id): + with connect() as db: + db.execute('BEGIN IMMEDIATE') + _get_entry(db, entry_id) + db.execute('DELETE FROM income_entries WHERE id=?', (entry_id,)) + + +def list_entries(year=None, month=None, asset_id=None, category=None, limit=None, offset=0, received=None, expected=None): # SQL fragments are constants. All filter values remain bound parameters. clauses, args = [], [] for sql, value in [("substr(i.date,1,4) = ?", str(year) if year else None), ("substr(i.date,6,2) = ?", f'{month:02}' if month else None), - ('i.asset_id = ?', asset_id), ('i.category = ?', category)]: + ('i.asset_id = ?', asset_id), ('i.category = ?', category), + ('i.received = ?', received), ('i.expected = ?', expected)]: if value is not None: clauses.append(sql) args.append(value) @@ -81,11 +101,12 @@ def available_years(): def dashboard(today=None): today = today or date.today() with connect() as db: + db.execute('BEGIN') # One read snapshot for all dashboard/statistics aggregates. + years_found = [int(r[0]) for r in db.execute('SELECT DISTINCT substr(date,1,4) FROM income_entries')] grouped = db.execute("SELECT substr(date,1,4) year, substr(date,6,2) month, SUM(amount) amount, COUNT(*) count FROM income_entries WHERE received=1 GROUP BY year, month").fetchall() - shares = [dict(r) for r in db.execute('SELECT a.name, SUM(i.amount) amount FROM income_entries i JOIN assets a ON a.id=i.asset_id WHERE received=1 GROUP BY a.id ORDER BY amount DESC')] + shares = [dict(r) for r in db.execute('SELECT a.id asset_id, a.name, SUM(i.amount) amount FROM income_entries i JOIN assets a ON a.id=i.asset_id WHERE received=1 GROUP BY a.id ORDER BY amount DESC')] kinds = {r['category']: r['amount'] for r in db.execute('SELECT category, SUM(amount) amount FROM income_entries WHERE received=1 GROUP BY category')} pending = db.execute('SELECT COUNT(*) count, COALESCE(SUM(amount),0) amount FROM income_entries WHERE expected=1 AND received=0').fetchone() - years_found = available_years() years = list(range(min(years_found + [today.year]), max(years_found + [today.year + 1]) + 1)) monthly = {year: [0] * 12 for year in years} count = 0 @@ -101,7 +122,7 @@ def dashboard(today=None): return dict(years=years, monthly=monthly, totals=totals, month=current_month, prior_month=prior_month, month_change=percent(current_month, prior_month), year=totals[today.year], prior_year=prior_year, year_change=percent(totals[today.year], prior_year), all_time=sum(totals.values()), count=count, - pending=dict(pending), today=today, shares=shares, + pending=dict(pending), today=today, shares=shares, categories=kinds, chart={'months': MONTHS, 'years': [{'label': str(y), 'data': monthly[y]} for y in years], 'shares': shares, 'kinds': [{'name': 'Dividenden / Ausschüttungen', 'amount': kinds.get('dividend',0)+kinds.get('distribution',0)}, {'name': 'Zinsen', 'amount': kinds.get('interest',0)}, {'name': 'Sonstiges', 'amount': kinds.get('other',0)}]}) diff --git a/docker-compose.yml b/docker-compose.yml index a3c9b04..9ea92ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,4 +7,6 @@ services: - "8081:8080" volumes: - ./data:/data + environment: + FINANCE_API_TOKEN: ${FINANCE_API_TOKEN:-} mem_limit: 128m diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..f2b4915 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,211 @@ +from datetime import date +from decimal import Decimal +import os +from pathlib import Path +import secrets +import sqlite3 +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'app')) +from fastapi.testclient import TestClient +from database import connect, initialize +from main import app +from models import decimal_string +from services.income_service import dashboard + + +class APITests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.token = secrets.token_urlsafe(32) + self.env = patch.dict(os.environ, {'FINANCE_DB_PATH': str(Path(self.temp.name) / 'finance.db'), + 'FINANCE_API_TOKEN': self.token}) + self.env.start() + self.client = TestClient(app) + self.client.__enter__() + self.headers = {'Authorization': f'Bearer {self.token}'} + with connect() as db: + self.asset_id = db.execute("SELECT id FROM assets WHERE name='Enbridge'").fetchone()[0] + + def tearDown(self): + self.client.__exit__(None, None, None) + self.env.stop() + self.temp.cleanup() + + def request(self, method, path, **kwargs): + return self.client.request(method, '/api/v1' + path, headers=self.headers, **kwargs) + + def create_income(self, **changes): + data = dict(date=date.today().isoformat(), asset_id=self.asset_id, category='dividend', + amount='0.04', note='Enbridge', expected=False, received=True) + data.update(changes) + response = self.request('POST', '/income', json=data) + self.assertEqual(response.status_code, 201, response.text) + return response.json() + + def test_all_routes_require_auth(self): + routes = [('GET','/assets'), ('POST','/assets'), ('GET','/assets/1'), ('PATCH','/assets/1'), ('DELETE','/assets/1'), + ('GET','/income'), ('POST','/income'), ('GET','/income/1'), ('PATCH','/income/1'), ('DELETE','/income/1'), + ('GET','/stats/summary'), ('GET','/stats/monthly'), ('GET','/stats/by-asset'), ('GET','/stats/by-category'), ('GET','/meta')] + for method, path in routes: + for headers in [{}, {'Authorization':'Bearer incorrect'}, {'Authorization':'Basic incorrect'}]: + response = self.client.request(method, '/api/v1'+path, headers=headers) + self.assertEqual(response.status_code, 401, (method,path,response.text)) + self.assertEqual(response.headers['www-authenticate'], 'Bearer') + self.assertEqual(self.request('GET', '/assets').status_code, 200) + self.assertEqual(self.client.get('/api/v1/assets?token='+self.token).status_code, 401) + + def test_unconfigured_api_keeps_web_available(self): + for value in ['', ' ', 'change-me']: + with patch.dict(os.environ, {'FINANCE_API_TOKEN':value}): + self.assertEqual(self.request('GET','/assets').status_code,503) + self.assertEqual(self.client.get('/').status_code,200) + self.assertEqual(self.client.get('/health').json(), {'status':'ok'}) + with patch.dict(os.environ): + del os.environ['FINANCE_API_TOKEN'] + self.assertEqual(self.client.get('/api/v1/meta').status_code,503) + + def test_assets_crud_duplicate_and_partial_updates(self): + data = dict(name='Test Position', ticker='TP', asset_type='etf', active=True) + response = self.request('POST','/assets',json=data) + self.assertEqual(response.status_code,201) + asset = response.json() + self.assertNotIn('normalized_name',asset) + self.assertEqual(self.request('GET',f"/assets/{asset['id']}").json(),asset) + self.assertEqual(self.request('POST','/assets',json={**data,'name':' TEST POSITION '}).status_code,409) + self.assertEqual(self.request('POST','/assets',json={**data,'name':'MSC'}).status_code,409) + result = self.request('PATCH',f"/assets/{asset['id']}",json={'ticker':None,'active':False}).json() + self.assertEqual(result['name'],asset['name']) + self.assertIsNone(result['ticker']) + self.assertFalse(result['active']) + self.assertIn(result,self.request('GET','/assets?active=false').json()) + self.assertNotIn(result,self.request('GET','/assets?active=true').json()) + self.assertEqual(self.request('PATCH',f"/assets/{asset['id']}",json={'name':'Enbridge'}).status_code,409) + self.assertEqual(self.request('DELETE',f"/assets/{asset['id']}").status_code,204) + + def test_asset_soft_delete_preserves_history_and_renames(self): + entry = self.create_income() + self.assertEqual(self.request('DELETE',f'/assets/{self.asset_id}').content,b'') + self.assertFalse(self.request('GET',f'/assets/{self.asset_id}').json()['active']) + self.assertEqual(self.request('GET',f"/income/{entry['id']}").status_code,200) + self.assertEqual(self.request('PATCH',f"/income/{entry['id']}",json={'note':'Korrektur'}).status_code,200) + self.assertEqual(self.request('POST','/income',json=dict(date='2026-09-02',asset_id=self.asset_id,category='dividend',amount='1')).status_code,422) + self.request('PATCH',f'/assets/{self.asset_id}',json={'name':'Enbridge umbenannt'}) + initialize() + names = [asset['name'] for asset in self.request('GET','/assets').json()] + self.assertIn('Enbridge umbenannt',names) + self.assertNotIn('Enbridge',names) + + def test_income_crud_partial_and_decimal(self): + entry = self.create_income(amount='0,04') + self.assertEqual(entry['amount'],'0.04') + self.assertEqual(entry['asset'],'Enbridge') + self.assertEqual(self.request('GET',f"/income/{entry['id']}").json(),entry) + changed = self.request('PATCH',f"/income/{entry['id']}",json={'amount':'28.00','note':None}).json() + self.assertEqual(changed['date'],entry['date']) + self.assertEqual(changed['amount'],'28.00') + self.assertTrue(changed['received']) + self.assertFalse(changed['expected']) + self.assertIsNone(changed['note']) + with connect() as db: + self.assertEqual(db.execute('SELECT amount FROM income_entries WHERE id=?',(entry['id'],)).fetchone()[0],2800) + response = self.request('DELETE',f"/income/{entry['id']}") + self.assertEqual(response.status_code,204) + self.assertEqual(response.content,b'') + self.assertEqual(self.request('GET',f"/income/{entry['id']}").status_code,404) + + def test_filters_and_pagination(self): + first = self.create_income(date='2025-09-02') + second = self.create_income(date='2026-09-02',received=False,expected=True,category='interest') + self.assertEqual(self.request('GET','/income?limit=1&offset=1').json()[0]['id'],first['id']) + result = self.request('GET',f'/income?year=2026&month=9&asset_id={self.asset_id}&category=interest&received=false&expected=true').json() + self.assertEqual([row['id'] for row in result],[second['id']]) + self.assertEqual(len(self.request('GET','/income?received=true&expected=false').json()),1) + for query in ['limit=1001','limit=0','offset=-1','month=13','year=0','received=unknown','category=invalid']: + self.assertEqual(self.request('GET','/income?'+query).status_code,422) + + def test_stats_share_dashboard_rules(self): + today = date.today() + self.create_income(amount='0.10') + self.create_income(amount='0.20',category='distribution') + self.create_income(amount='0.30',category='interest') + self.create_income(amount='0.40',category='other') + self.create_income(date=f'{today.year-1}-{today.month:02}-01',amount='0.50') + self.create_income(amount='28.00',expected=True,received=False) + stats = dashboard() + result = self.request('GET','/stats/summary').json() + self.assertEqual(result['current_month'],'1.00') + self.assertEqual(result['current_year'],'1.00') + self.assertEqual(result['previous_year'],'0.50') + self.assertEqual(result['current_month_yoy_percent'],'100.00') + self.assertEqual(result['current_year_yoy_percent'],'100.00') + self.assertEqual(result['all_time'],decimal_string(stats['all_time'])) + self.assertEqual(result['current_year_payment_count'],4) + monthly = self.request('GET',f'/stats/monthly?year={today.year}').json()[0] + self.assertEqual(len(monthly['months']),12) + self.assertEqual(monthly['months'][today.month-1]['amount'],'1.00') + shares = self.request('GET','/stats/by-asset').json() + self.assertEqual(shares[0]['amount'],'1.50') + self.assertEqual(shares[0]['percentage'],'100.00') + kinds = self.request('GET','/stats/by-category').json() + self.assertEqual({row['category'] for row in kinds},{'dividend','interest','distribution','other'}) + self.assertEqual(sum(Decimal(row['amount']) for row in kinds),Decimal('1.50')) + + def test_empty_stats_and_zero_denominator(self): + result = self.request('GET','/stats/summary').json() + self.assertEqual(result['all_time'],'0.00') + self.assertIsNone(result['current_year_yoy_percent']) + self.assertEqual(self.request('GET','/stats/by-asset').json(),[]) + self.assertTrue(all(row['percentage'] is None for row in self.request('GET','/stats/by-category').json())) + self.assertEqual(self.request('GET','/stats/monthly?year=2024').json()[0]['total'],'0.00') + + def test_validation_and_unknown_ids(self): + base = dict(date='2026-09-02',asset_id=self.asset_id,category='dividend',amount='0.04') + for change in [{'amount':0.04},{'amount':'0.001'},{'amount':'NaN'},{'amount':'Infinity'},{'date':'2026-02-30'}, + {'asset_id':99999},{'asset_id':True},{'category':'invalid'},{'received':None},{'received':'false'}, {'surprise':'field'}]: + response = self.request('POST','/income',json={**base,**change}) + self.assertEqual(response.status_code,422,response.text) + for change in [{'name':' '},{'name':'!!!'},{'name':None},{'asset_type':'invalid'}]: + self.assertEqual(self.request('POST','/assets',json={'name':'Valid','asset_type':'stock',**change}).status_code,422) + entry = self.create_income() + for change in [{'amount':None},{'date':None},{'received':None}]: + self.assertEqual(self.request('PATCH',f"/income/{entry['id']}",json=change).status_code,422) + for resource in ['assets','income']: + for method in ['GET','PATCH','DELETE']: + kwargs = {'json':{}} if method=='PATCH' else {} + self.assertEqual(self.request(method,f'/{resource}/999999',**kwargs).status_code,404) + + def test_web_and_api_use_same_entries(self): + response = self.client.post('/income/new',data=dict(date='2026-09-02',asset_id=self.asset_id, + category='dividend',amount='0,04',received='1'),follow_redirects=False) + self.assertEqual(response.status_code,303) + entry = self.request('GET','/income').json()[0] + self.request('PATCH',f"/income/{entry['id']}",json={'amount':'12.34'}) + self.assertIn('12,34 €',self.client.get('/income').text) + self.client.post(f"/income/{entry['id']}/delete") + self.assertEqual(self.request('GET','/income').json(),[]) + + def test_openapi_meta_errors_and_no_cors(self): + schema = self.client.get('/openapi.json').json() + for path, methods in schema['paths'].items(): + if path.startswith('/api/v1/'): + for operation in methods.values(): + self.assertEqual(operation['security'],[{'FinanceAPIToken':[]}]) + self.assertEqual(self.request('GET','/meta').json(),{'name':'Finance Dashboard','api_version':'v1','database':'ok'}) + response = self.client.get('/api/v1/assets',headers={**self.headers,'Origin':'https://example.com'}) + self.assertNotIn('access-control-allow-origin',response.headers) + with patch('api.routes.asset_service.list_assets',side_effect=RuntimeError('private-internal-information')): + response = self.request('GET','/assets') + self.assertEqual(response.status_code,500) + self.assertNotIn('private-internal-information',response.text) + with patch('api.routes.asset_service.list_assets',side_effect=sqlite3.OperationalError('secret-path')): + response = self.request('GET','/assets') + self.assertEqual(response.status_code,503) + self.assertNotIn('secret-path',response.text) + + +if __name__ == '__main__': + unittest.main()