Add authenticated finance REST API

This commit is contained in:
kai
2026-09-09 09:47:56 +02:00
parent afc6f74f36
commit ad452045be
13 changed files with 760 additions and 44 deletions
+1
View File
@@ -0,0 +1 @@
FINANCE_API_TOKEN=change-me
+109
View File
@@ -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. 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). 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=<langes-zufälliges-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/).
+16
View File
@@ -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'})
+155
View File
@@ -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()
+133
View File
@@ -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'
+12 -5
View File
@@ -23,13 +23,18 @@ def connect(path=None):
db.close() 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) name = canonical_name(name)
if asset_type not in ASSET_TYPES: if asset_type not in ASSET_TYPES:
raise ValueError('Ungültiger Positionstyp.') raise ValueError('Ungültiger Positionstyp.')
ticker = (ticker or '').strip() ticker = (ticker or '').strip()
if len(ticker) > 30: if len(ticker) > 30:
raise ValueError('Ticker darf maximal 30 Zeichen enthalten.') 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', 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)) (name, name_key(name), ticker or None, asset_type))
return db.execute('SELECT id FROM assets WHERE normalized_name = ?', (name_key(name),)).fetchone()['id'] 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 = Path(path or db_path())
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
with connect(target) as db: 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.execute('PRAGMA journal_mode = WAL')
db.executescript(''' db.executescript('''
CREATE TABLE IF NOT EXISTS assets ( CREATE TABLE IF NOT EXISTS assets (
@@ -67,7 +73,8 @@ def initialize(path=None):
); );
PRAGMA user_version = 1; PRAGMA user_version = 1;
''') ''')
for name, kind in [('AGNC','stock'), ('Main Street Capital','stock'), ('Capital Southwest','stock'), if first_setup:
('Ares Capital','stock'), ('Realty Income','stock'), ('Enbridge','stock'), for name, kind in [('AGNC','stock'), ('Main Street Capital','stock'), ('Capital Southwest','stock'),
('Bayer','stock'), ('STOXX Global Select Dividend 100','etf'), ('airBaltic','bond')]: ('Ares Capital','stock'), ('Realty Income','stock'), ('Enbridge','stock'),
ensure_asset(db, name, kind) ('Bayer','stock'), ('STOXX Global Select Dividend 100','etf'), ('airBaltic','bond')]:
ensure_asset(db, name, kind)
+3 -1
View File
@@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from database import initialize from database import initialize
from routes import dashboard, income, export from routes import dashboard, income, export
from api.routes import router as api_router
@asynccontextmanager @asynccontextmanager
@@ -20,11 +21,12 @@ app.mount('/static', StaticFiles(directory=Path(__file__).parent / 'static'), na
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(income.router) app.include_router(income.router)
app.include_router(export.router) app.include_router(export.router)
app.include_router(api_router)
@app.middleware('http') @app.middleware('http')
async def protect_forms(request: Request, call_next): 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') origin = request.headers.get('origin')
if request.headers.get('sec-fetch-site') == 'cross-site' or (origin and urlsplit(origin).netloc != request.headers.get('host')): 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) return HTMLResponse('Fremder Formularursprung ist nicht erlaubt.', status_code=403)
+6 -1
View File
@@ -64,6 +64,11 @@ ALIASES = {
def canonical_name(value): def canonical_name(value):
name = ' '.join(str(value).split()) name = ' '.join(str(value).split())
name = re.sub(r'^(dividende[n]?|ausschüttung)\s+', '', name, flags=re.I) 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.') raise ValueError('Positionsname muss zwischen 1 und 150 Zeichen lang sein.')
return ALIASES.get(name_key(name), name) 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}'
+4 -9
View File
@@ -3,9 +3,9 @@ from decimal import Decimal
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Form, HTTPException, Query, Request from fastapi import APIRouter, Form, HTTPException, Query, Request
from fastapi.responses import RedirectResponse 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 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 from views import render
router = APIRouter() router = APIRouter()
@@ -68,11 +68,7 @@ def save(request: Request, date: Annotated[str, Form()] = '', asset_id: Annotate
@router.post('/income/{entry_id}/delete') @router.post('/income/{entry_id}/delete')
def delete(entry_id: int): def delete(entry_id: int):
if not 1 <= entry_id <= 9223372036854775807: delete_entry(entry_id)
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.')
return RedirectResponse('/?message=deleted', status_code=303) return RedirectResponse('/?message=deleted', status_code=303)
@@ -84,8 +80,7 @@ def new_asset(request: Request):
@router.post('/assets/new') @router.post('/assets/new')
def create_asset(request: Request, name: Annotated[str, Form()], asset_type: Annotated[str, Form()], ticker: Annotated[str, Form()] = ''): def create_asset(request: Request, name: Annotated[str, Form()], asset_type: Annotated[str, Form()], ticker: Annotated[str, Form()] = ''):
try: try:
with connect() as db: asset_id = create_asset_record(name, asset_type, ticker, reuse=True)['id']
asset_id = ensure_asset(db, name, asset_type, ticker)
except ValueError as error: 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 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) return RedirectResponse(f'/income/new?asset_id={asset_id}', status_code=303)
+59
View File
@@ -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})
+49 -28
View File
@@ -1,27 +1,25 @@
from datetime import date from datetime import date
from fastapi import HTTPException from fastapi import HTTPException
from database import connect 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(): def _get_entry(db, entry_id):
with connect() as db:
return db.execute('SELECT * FROM assets ORDER BY name COLLATE NOCASE').fetchall()
def get_entry(entry_id):
if not 1 <= entry_id <= 9223372036854775807: if not 1 <= entry_id <= 9223372036854775807:
raise HTTPException(404, 'Zahlung nicht gefunden.') raise HTTPException(404, 'Zahlung nicht gefunden.')
with connect() as db: 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()
row = db.execute('SELECT * FROM income_entries WHERE id = ?', (entry_id,)).fetchone()
if row is None: if row is None:
raise HTTPException(404, 'Zahlung nicht gefunden.') raise HTTPException(404, 'Zahlung nicht gefunden.')
return dict(row) return dict(row)
def save_entry(data, entry_id=None): def get_entry(entry_id):
if entry_id is not None and not 1 <= entry_id <= 9223372036854775807: with connect() as db:
raise HTTPException(404, 'Zahlung nicht gefunden.') return _get_entry(db, entry_id)
def _validated_values(data, db, existing):
day = valid_date(data.get('date', '')) day = valid_date(data.get('date', ''))
amount = cents(data.get('amount', '')) amount = cents(data.get('amount', ''))
category = data.get('category', '') category = data.get('category', '')
@@ -33,32 +31,54 @@ def save_entry(data, entry_id=None):
raise ValueError raise ValueError
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValueError('Bitte eine Position auswählen.') from None raise ValueError('Bitte eine Position auswählen.') from None
note = data.get('note', '').strip() note = (data.get('note') or '').strip()
if len(note) > 2000: if len(note) > 2000:
raise ValueError('Notiz darf maximal 2000 Zeichen enthalten.') 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: with connect() as db:
db.execute('BEGIN IMMEDIATE') db.execute('BEGIN IMMEDIATE')
existing = db.execute('SELECT * FROM income_entries WHERE id = ?', (entry_id,)).fetchone() if entry_id else None existing = _get_entry(db, entry_id) if entry_id is not None else None
if entry_id and existing is None: if partial:
raise HTTPException(404, 'Zahlung nicht gefunden.') merged = dict(existing)
asset = db.execute('SELECT * FROM assets WHERE id = ?', (asset_id,)).fetchone() merged['amount'] = decimal_string(existing['amount'])
if asset is None or (not asset['active'] and (existing is None or existing['asset_id'] != asset_id)): merged.update(data)
raise ValueError('Diese Position ist nicht mehr verfügbar.') data = merged
values = (day, asset_id, category, amount, note or None, expected, received) values = _validated_values(data, db, existing)
if entry_id: 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)) 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: else:
entry_id = db.execute('INSERT INTO income_entries (date,asset_id,category,amount,note,expected,received) VALUES (?,?,?,?,?,?,?)', values).lastrowid 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. # SQL fragments are constants. All filter values remain bound parameters.
clauses, args = [], [] clauses, args = [], []
for sql, value in [("substr(i.date,1,4) = ?", str(year) if year else None), 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), ("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: if value is not None:
clauses.append(sql) clauses.append(sql)
args.append(value) args.append(value)
@@ -81,11 +101,12 @@ def available_years():
def dashboard(today=None): def dashboard(today=None):
today = today or date.today() today = today or date.today()
with connect() as db: 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() 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')} 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() 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)) years = list(range(min(years_found + [today.year]), max(years_found + [today.year + 1]) + 1))
monthly = {year: [0] * 12 for year in years} monthly = {year: [0] * 12 for year in years}
count = 0 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, 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, 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, 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], 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)}, '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)}]}) {'name': 'Zinsen', 'amount': kinds.get('interest',0)}, {'name': 'Sonstiges', 'amount': kinds.get('other',0)}]})
+2
View File
@@ -7,4 +7,6 @@ services:
- "8081:8080" - "8081:8080"
volumes: volumes:
- ./data:/data - ./data:/data
environment:
FINANCE_API_TOKEN: ${FINANCE_API_TOKEN:-}
mem_limit: 128m mem_limit: 128m
+211
View File
@@ -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()