Add SQLite passive income dashboard with Excel import and deployment tools

This commit is contained in:
kai
2026-09-09 08:46:27 +02:00
parent 729a13bfca
commit afc6f74f36
27 changed files with 1109 additions and 67 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.agents
.codex
.venv
__pycache__
**/__pycache__
data
import
*.xlsx
.env
+6
View File
@@ -2,3 +2,9 @@ __pycache__/
*.pyc
.env
data/
.venv/
*.db
*.db-wal
*.db-shm
import/
*.xlsx
+137
View File
@@ -0,0 +1,137 @@
# Finance Dashboard
Privates, schlankes Dashboard für passives Einkommen auf pinguAurora. FastAPI, Jinja2 und SQLite; Python 3.13 im Docker-Image. Kein Node, keine externen Finanz-APIs. Docker-Port **8081 → 8080**, Daten-Volume **./data → /data**, RAM-Limit **128 MiB** bleiben bestehen.
## Funktionen
- Acht Kennzahlen, dynamischer Monats-/Jahresvergleich und drei Chart.js-Diagramme.
- Zahlungen anlegen, bearbeiten, mit Bestätigung löschen; Positionen ergänzen.
- Vollständige Historie unter `/income`, nach Jahr, Monat, Position und Kategorie filterbar, 100 Buchungen pro Seite.
- CSV-Export, wiederholbarer XLSX-Import und getrennte Erfassung erwarteter/erhaltener Zahlungen.
- Alle Summen basieren ausschließlich auf `received=1`. Der Jahresvergleich vergleicht das aktuelle Kalenderjahr mit dem **gesamten** Vorjahr, kein YTD-Vergleich. Bei Vorjahreswert 0 erscheint ``.
- Jahres-Spalten entstehen aus den vorhandenen Buchungsjahren, dem aktuellen Jahr und dem nächsten Kalenderjahr als Ausblick; Lücken werden ergänzt. Es sind keine konkreten Jahreszahlen im Dashboard fest programmiert.
## Lokale Entwicklung
```bash
python3.13 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
export FINANCE_DB_PATH="$PWD/data/finance.db"
uvicorn main:app --app-dir app --reload --host 127.0.0.1 --port 8081
```
`requirements.txt` enthält nur die Web-Abhängigkeiten, einschließlich `python-multipart` für HTML-Formulare. SQLite ist Teil von Python. `openpyxl` wird nur beim Import benötigt; `httpx` nur für Tests. Templates und statische Dateien werden relativ zum Anwendungscode gefunden, im Container also unter `/app/templates` und `/app/static`.
```bash
.venv/bin/python -m unittest discover -s tests -v
docker compose config
```
Tests verwenden ausschließlich temporäre Datenbanken. Die Entwicklungsbibliotheken werden nicht im Produktionsimage installiert.
## Docker Build und Start
```bash
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
```
Die App ist unter `http://localhost:8081` erreichbar. `/health` liefert `{"status":"ok"}`. `python:3.13-slim` wird ohne feste CPU-Plattform verwendet; auf dem Raspberry wird nativ für ARM64 gebaut.
## SQLite und Geldbeträge
Standardpfad: **/data/finance.db**, persistent auf dem Host als **./data/finance.db**. Für lokale Entwicklung/Import ist `FINANCE_DB_PATH` oder beim Import `--db` verfügbar. Tabellen und die neun Grundpositionen werden beim Start automatisch angelegt.
`income_entries.amount` enthält **ganze Cent (INTEGER)**, keine Euro-Floats. Python summiert Integer und berechnet Prozentwerte mit Decimal. Formulare akzeptieren `0,04`, `0.04` und `28,00`, ohne Tausendertrennzeichen. Mehr als zwei Nachkommastellen werden abgelehnt. Negative Beträge sind für Korrekturen erlaubt. Nur Chart.js verwendet für die grafische Anzeige JavaScript-Zahlen; das ändert keine Finanzwerte in SQLite.
SQLite nutzt Foreign Keys, WAL, kurze Transaktionen und fünf Sekunden Wartezeit bei Locks. Referenzierte Positionen können nicht physisch gelöscht werden; `active=0` deaktiviert sie für neue Buchungen, vorhandene Historie bleibt erhalten. Bei temporären Datenbankproblemen antwortet die App mit HTTP 503. Schema-Version 1 wird über `PRAGMA user_version` geführt.
`expected=1, received=0` bezeichnet eine offene/ausgefallene erwartete Zahlung. Der Betrag enthält dann die Erwartung, fließt aber **nicht** in tatsächliche Summen ein. Eine teilweise erhaltene Zahlung wird als erhaltene Buchung plus separate offene Restbuchung erfasst. Es gibt noch keine automatische Prognose oder Fälligkeitsverwaltung.
## Excel-Import
SQLite ist die primäre Datenquelle. Excel wird nach dem Import nicht im laufenden Dashboard gelesen. Die XLSX-Datei bleibt lokal im ignorierten Verzeichnis `import/`.
```bash
source .venv/bin/activate
pip install -r requirements-import.txt
python scripts/import_excel.py import/tr_verbessert_2027_vergleiche_enbridge.xlsx \
--db ./data/finance.db --dry-run
python scripts/import_excel.py import/tr_verbessert_2027_vergleiche_enbridge.xlsx \
--db ./data/finance.db
```
Ohne `--db` verwendet das Script `FINANCE_DB_PATH` bzw. `/data/finance.db`:
```bash
python scripts/import_excel.py /pfad/datei.xlsx
```
Es wird nur das **erste Tabellenblatt** gelesen. Der Import sucht den Buchungskopf `Datum`, `Art des Ertrags`/`Position`, `Betrag (€)` und `Kategorie`. Dashboard-Zellen davor und weitere Blätter werden ignoriert. Ungültige Buchungen oder Formeln innerhalb der Buchungsfelder brechen den gesamten Import mit Zeilenangabe ab; es werden keine Teilimporte gespeichert. `--dry-run` rollt Buchungsänderungen zurück, legt jedoch bei Bedarf Datenbank und Seed an.
Bekannte Aliase werden normalisiert: **MSC → Main Street Capital**, CSW/CSWC → Capital Southwest, PC → Prospect Capital, Stoxx → STOXX Global Select Dividend 100, Air Baltic → airBaltic. Präfixe wie „Dividende“ werden entfernt. Groß-/Kleinschreibung, Leerzeichen und Satzzeichen führen nicht zu neuen Positionen. Unbekannte Positionen werden angelegt; unsichere Namen werden nicht anhand von Ähnlichkeit zusammengelegt. Historische airBaltic-Buchungen mit Dividendenkategorie werden als Anleihezinsen übernommen und mit einer Notiz gekennzeichnet.
Dublettenidentität: Datum, normalisierte Position, Kategorie, Centbetrag, Erwartet-/Erhalten-Status und Vorkommensnummer. Dadurch bleiben mehrere identische echte Zahlungen in einer Datei erhalten, während wiederholte Imports und passende manuelle Buchungen wiederverwendet werden. `import_records` merkt sich den Import auch nach Bearbeiten/Löschen einer Buchung; ein erneuter Import stellt gelöschte Buchungen nicht wieder her. Änderungen an identitätsbildenden Excel-Feldern gelten als neue Buchungen: Korrekturen nach dem Erstimport deshalb im Dashboard vornehmen. Die Excel anschließend als Archiv behandeln.
Die vorliegende Excel besitzt im Ertragsbuch keine Erwartet-/Erhalten-Spalten. Diese historischen Buchungen gelten als erhalten. Aus widersprüchlichen Dashboard-Texten werden **keine zusätzlichen Zahlungen oder Ausfälle erfunden**. Falls eine historische Buchung tatsächlich ausgefallen ist, ihren Status nach fachlicher Prüfung im Dashboard korrigieren. Optionale Importspalten `Erwartet`/`Erhalten` unterstützen Ja/Nein, true/false und 1/0.
Das Script zeigt einen Plausibilitätscheck: September 2026 **3,71 €**, einschließlich Enbridge am **02.09.2026 mit 0,04 €**. Abweichungen werden gemeldet, nicht durch erfundene Buchungen ausgeglichen.
Falls `data/` bereits durch Docker angelegt wurde und für deinen lokalen Benutzer nicht beschreibbar ist, kann der Import im temporären Container laufen. Die Importbibliothek wird dabei nicht im Produktionsimage gespeichert:
```bash
docker compose run --rm --no-deps -v "$PWD:/workspace:ro" finance-dashboard sh -c \
'pip install --no-cache-dir --target /tmp/import-deps openpyxl && PYTHONPATH=/tmp/import-deps python /workspace/scripts/import_excel.py /workspace/import/tr_verbessert_2027_vergleiche_enbridge.xlsx'
```
## CSV-Export
`/export/income.csv` exportiert **alle** Buchungen einschließlich offener Zahlungen: Datum, Position, Kategorie, Betrag, Notiz, Erwartet, Erhalten. UTF-8 mit BOM, Semikolon, Dezimalkomma und CRLF für Excel. Gefährliche Formelpräfixe in Textfeldern erhalten ein schützendes Apostroph. Export ist eine Buchungsliste, kein vollständiges Datenbankbackup.
## Backup und Wiederherstellung
`data/`, SQLite-Dateien, Excel-Dateien und `.env` werden von Git ausgeschlossen. `.dockerignore` hält sie auch aus dem Build-Kontext fern. **Nie nur die laufende SQLite-Hauptdatei kopieren:** Im WAL-Modus können neuere Transaktionen noch in `-wal` liegen.
Konsistentes Backup während des Betriebs mit der SQLite-Backup-API:
```bash
mkdir -p backups
docker compose exec -T finance-dashboard python -c 'import sqlite3; src=sqlite3.connect("/data/finance.db"); dst=sqlite3.connect("/data/finance-backup.db"); src.backup(dst); dst.close(); src.close()'
cp data/finance-backup.db "backups/finance-$(date +%Y%m%d-%H%M%S).db"
```
Backup zusätzlich auf einem anderen Datenträger sichern. Wiederherstellung nur bei gestoppter App: vorhandenes `data/` vollständig beiseite sichern (inkl. WAL/SHM), ein frisches `data/` anlegen, die Backup-Datei dort als `finance.db` einsetzen und Dateirechte prüfen. Anschließend `docker compose up -d --no-build`. Das Deploy-Script führt keine Wiederherstellung durch und löscht keine Daten.
## Deployment auf pinguAurora
Wegen des aktuellen Buildx-Versionskonflikts **nicht `docker compose up -d --build` verwenden**. Im bestehenden Checkout auf dem Raspberry:
```bash
git pull
DOCKER_BUILDKIT=0 docker build \
-t finance-dashboard-finance-dashboard:latest \
.
docker compose up -d --no-build
```
Oder im Checkout einfach:
```bash
./deploy.sh
```
Das ausführbare Script wechselt in sein eigenes Projektverzeichnis, nutzt `git pull --ff-only`, führt genau den klassischen Build und den Start ohne Build aus, zeigt den Containerstatus und prüft `http://127.0.0.1:8081/health` mit Wiederholungen. Bei einem Fehler bricht es ab. Es verändert keine Datenbankdateien. Zugriff: `http://pinguAurora:8081`.
Die lokale Datenbank wird **nicht mit Git übertragen**. Für die Erstübernahme auf dem Raspberry entweder die XLSX-Datei separat übertragen und das Import-Script dort in einer Python-Umgebung mit `requirements-import.txt` gegen `./data/finance.db` ausführen, oder ein konsistentes SQLite-Backup vor dem ersten Start in das dortige `data/` übernehmen. Vor einem Import in einen vorhandenen Datenbestand ein Backup erstellen.
## Betrieb und Quellen
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).
+73
View File
@@ -0,0 +1,73 @@
"""Small SQLite layer. Each operation owns its connection and transaction."""
import os
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from models import canonical_name, name_key, ASSET_TYPES
def db_path():
return Path(os.environ.get('FINANCE_DB_PATH', '/data/finance.db'))
@contextmanager
def connect(path=None):
db = sqlite3.connect(path or db_path(), timeout=5)
db.row_factory = sqlite3.Row
db.execute('PRAGMA foreign_keys = ON')
db.execute('PRAGMA busy_timeout = 5000')
try:
with db:
yield db
finally:
db.close()
def ensure_asset(db, 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.')
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']
def initialize(path=None):
target = Path(path or db_path())
target.parent.mkdir(parents=True, exist_ok=True)
with connect(target) as db:
db.execute('PRAGMA journal_mode = WAL')
db.executescript('''
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY, name TEXT NOT NULL,
normalized_name TEXT NOT NULL UNIQUE, ticker TEXT,
asset_type TEXT NOT NULL CHECK(asset_type IN ('stock','etf','bond','crypto','interest','other')),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS income_entries (
id INTEGER PRIMARY KEY, date TEXT NOT NULL,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
category TEXT NOT NULL CHECK(category IN ('dividend','interest','distribution','other')),
amount INTEGER NOT NULL CHECK(typeof(amount) = 'integer' AND abs(amount) <= 99999999999),
note TEXT, expected INTEGER NOT NULL DEFAULT 0 CHECK(expected IN (0,1)),
received INTEGER NOT NULL DEFAULT 1 CHECK(received IN (0,1)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS income_date ON income_entries(date DESC, id DESC);
CREATE INDEX IF NOT EXISTS income_asset ON income_entries(asset_id);
CREATE TABLE IF NOT EXISTS import_records (
fingerprint TEXT PRIMARY KEY,
entry_id INTEGER REFERENCES income_entries(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
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)
+35 -10
View File
@@ -1,16 +1,41 @@
import sqlite3
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import urlsplit
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
app = FastAPI(title="Finance Dashboard")
templates = Jinja2Templates(directory="/app/templates")
from fastapi.staticfiles import StaticFiles
from database import initialize
from routes import dashboard, income, export
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse(request=request, name="index.html")
@asynccontextmanager
async def lifespan(app):
initialize()
yield
@app.get("/health")
async def health():
return {"status": "ok"}
app = FastAPI(title='Finance Dashboard', lifespan=lifespan)
app.mount('/static', StaticFiles(directory=Path(__file__).parent / 'static'), name='static')
app.include_router(dashboard.router)
app.include_router(income.router)
app.include_router(export.router)
@app.middleware('http')
async def protect_forms(request: Request, call_next):
if request.method == 'POST':
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)
return await call_next(request)
@app.exception_handler(sqlite3.OperationalError)
async def database_error(request, error):
return HTMLResponse('<html lang="de"><meta charset="utf-8"><h1>Datenbank vorübergehend nicht verfügbar</h1><p>Bitte in einigen Sekunden erneut versuchen.</p><a href="/">Zum Dashboard</a></html>', status_code=503, headers={'Retry-After': '5'})
@app.get('/health')
def health():
return {'status': 'ok'}
+69
View File
@@ -0,0 +1,69 @@
"""Shared validation and cent-exact money formatting."""
import re
import unicodedata
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
CATEGORIES = {'dividend': 'Dividende', 'interest': 'Zinsen', 'distribution': 'Ausschüttung', 'other': 'Sonstiges'}
ASSET_TYPES = {'stock': 'Aktie', 'etf': 'ETF', 'bond': 'Anleihe', 'crypto': 'Krypto', 'interest': 'Zinskonto', 'other': 'Sonstiges'}
MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
def cents(value):
text = str(value).strip()
if not re.fullmatch(r'-?\d{1,10}(?:[.,]\d{1,2})?', text):
raise ValueError('Betrag mit höchstens zwei Nachkommastellen eingeben, ohne Tausendertrennzeichen.')
try:
number = Decimal(text.replace(',', '.'))
except InvalidOperation:
raise ValueError('Ungültiger Betrag.') from None
if abs(number) > Decimal('999999999.99'):
raise ValueError('Betrag ist zu groß.')
return int(number * 100)
def money(value):
return f'{Decimal(value) / 100:,.2f}'.replace(',', '_').replace('.', ',').replace('_', '.') + ''
def percent(current, previous):
if not previous:
return None
return (Decimal(current - previous) * 100 / abs(Decimal(previous))).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
def percent_text(value):
return '' if value is None else f'{value:.2f}'.replace('.', ',') + ' %'
def valid_date(value):
if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', str(value)):
raise ValueError('Bitte ein gültiges Datum eingeben.')
try:
return date.fromisoformat(value).isoformat()
except ValueError:
raise ValueError('Bitte ein gültiges Datum eingeben.') from None
def name_key(name):
return ''.join(c for c in unicodedata.normalize('NFKC', name).casefold() if c.isalnum())
ALIASES = {
'msc': 'Main Street Capital', 'mainstreet': 'Main Street Capital', 'mainstreetcapital': 'Main Street Capital',
'agnc': 'AGNC', 'agncinvestment': 'AGNC', 'agncinvestmentcorp': 'AGNC',
'csw': 'Capital Southwest', 'cswc': 'Capital Southwest', 'capitalsouthwest': 'Capital Southwest',
'arcc': 'Ares Capital', 'arescapital': 'Ares Capital', 'realtyincome': 'Realty Income',
'enbridge': 'Enbridge', 'bayer': 'Bayer', 'stoxx': 'STOXX Global Select Dividend 100',
'stoxxglobalselectdividend100': 'STOXX Global Select Dividend 100',
'airbaltic': 'airBaltic', 'pc': 'Prospect Capital', 'psec': 'Prospect Capital',
'prospectcapital': 'Prospect Capital', 'komischen26dividende': 'N26 Dividende',
}
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:
raise ValueError('Positionsname muss zwischen 1 und 150 Zeichen lang sein.')
return ALIASES.get(name_key(name), name)
+10
View File
@@ -0,0 +1,10 @@
from fastapi import APIRouter, Request
from services.income_service import dashboard, list_entries
from views import render
router = APIRouter()
@router.get('/')
def index(request: Request):
return render(request, 'index.html', {'stats': dashboard(), 'entries': list_entries(limit=20)})
+35
View File
@@ -0,0 +1,35 @@
import csv
import io
from decimal import Decimal
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from database import connect
from models import CATEGORIES
router = APIRouter()
def safe_cell(value):
text = str(value or '')
return "'" + text if text.lstrip().startswith(('=', '+', '-', '@')) or text.startswith(('\t', '\r', '\n')) else text
def csv_rows():
stream = io.StringIO(newline='')
writer = csv.writer(stream, delimiter=';', lineterminator='\r\n')
yield '\ufeff'
writer.writerow(['Datum', 'Position', 'Kategorie', 'Betrag', 'Notiz', 'Erwartet', 'Erhalten'])
yield stream.getvalue()
stream.seek(0); stream.truncate(0)
with connect() as db:
for row in db.execute('SELECT i.*, a.name FROM income_entries i JOIN assets a ON a.id=i.asset_id ORDER BY date DESC, i.id DESC'):
writer.writerow([row['date'], safe_cell(row['name']), CATEGORIES[row['category']],
f"{Decimal(row['amount'])/100:.2f}".replace('.', ','), safe_cell(row['note']),
'Ja' if row['expected'] else 'Nein', 'Ja' if row['received'] else 'Nein'])
yield stream.getvalue()
stream.seek(0); stream.truncate(0)
@router.get('/export/income.csv')
def export():
return StreamingResponse(csv_rows(), media_type='text/csv; charset=utf-8', headers={'Content-Disposition': 'attachment; filename="income.csv"'})
+91
View File
@@ -0,0 +1,91 @@
from datetime import date
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 models import CATEGORIES
from services.income_service import assets, available_years, get_entry, list_entries, save_entry
from views import render
router = APIRouter()
def form_page(request, data, error=None, status=200, entry_id=None):
return render(request, 'income_form.html', {'data': data, 'assets': assets(), 'error': error, 'entry_id': entry_id}, status)
@router.get('/income')
def history(request: Request, year: str | None = None,
month: str | None = None,
asset_id: str | None = None, category: str | None = None,
page: Annotated[int, Query(ge=1, le=1000000)] = 1):
def number(value, maximum):
if not value:
return None
try:
parsed = int(value)
if not 1 <= parsed <= maximum:
raise ValueError
return parsed
except ValueError:
raise HTTPException(422, 'Ungültiger Filter.') from None
year, month, asset_id = number(year, 9999), number(month, 12), number(asset_id, 9223372036854775807)
category = category or None
if category is not None and category not in CATEGORIES:
raise HTTPException(422, 'Ungültige Kategorie.')
rows = list_entries(year, month, asset_id, category, limit=101, offset=(page-1)*100)
return render(request, 'income.html', dict(entries=rows[:100], more=len(rows)>100, page=page,
assets=assets(), years=available_years(), filters=dict(year=year, month=month, asset_id=asset_id, category=category)))
@router.get('/income/new')
def new(request: Request):
return form_page(request, {'date': date.today().isoformat(), 'received': True, 'expected': False,
'asset_id': request.query_params.get('asset_id', ''), 'category': 'dividend'})
@router.get('/income/{entry_id}/edit')
def edit(request: Request, entry_id: int):
data = get_entry(entry_id)
data['amount'] = str(Decimal(data['amount']) / 100).replace('.', ',')
return form_page(request, data, entry_id=entry_id)
@router.post('/income/new')
@router.post('/income/{entry_id}/edit')
def save(request: Request, date: Annotated[str, Form()] = '', asset_id: Annotated[str, Form()] = '',
category: Annotated[str, Form()] = '', amount: Annotated[str, Form()] = '',
note: Annotated[str, Form()] = '', expected: Annotated[str, Form()] = '',
received: Annotated[str, Form()] = '', entry_id: int | None = None):
data = dict(date=date, asset_id=asset_id, category=category, amount=amount, note=note, expected=expected, received=received)
try:
save_entry(data, entry_id)
except ValueError as error:
return form_page(request, data, str(error), 422, entry_id)
return RedirectResponse('/?message=saved', status_code=303)
@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.')
return RedirectResponse('/?message=deleted', status_code=303)
@router.get('/assets/new')
def new_asset(request: Request):
return render(request, 'asset_form.html', {'data': {}})
@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)
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)
+147
View File
@@ -0,0 +1,147 @@
"""Read only the ledger in the first worksheet; never import dashboard cells."""
from collections import Counter
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP
import hashlib
import json
from database import connect, ensure_asset, initialize
from models import canonical_name, cents, name_key, valid_date
HEADERS = {
'date': {'datum', 'date'}, 'name': {'artdesertrags', 'position', 'asset'},
'amount': {'betrag', 'betrageur', 'amount'}, 'category': {'kategorie', 'category'},
'note': {'notiz', 'note'}, 'expected': {'erwartet', 'expected'}, 'received': {'erhalten', 'received'},
}
CATEGORY_MAP = {'dividende': 'dividend', 'dividenden': 'dividend', 'dividend': 'dividend',
'dividendenausschüttungen': 'dividend', 'ausschüttung': 'distribution',
'ausschüttungen': 'distribution', 'distribution': 'distribution',
'zinsen': 'interest', 'zins': 'interest', 'interest': 'interest',
'sonstiges': 'other', 'sonstige': 'other', 'other': 'other'}
def boolean(value, default):
if value is None or value == '':
return default
key = str(value).strip().casefold()
if key in {'1', 'true', 'ja', 'yes', 'wahr'}:
return 1
if key in {'0', 'false', 'nein', 'no', 'falsch'}:
return 0
raise ValueError('Ungültiger Erwartet-/Erhalten-Wert.')
def read_ledger(filename):
# Only the standalone importer needs openpyxl, never the running web app.
from openpyxl import load_workbook
from openpyxl.utils.datetime import from_excel
book = load_workbook(filename, read_only=True, data_only=False)
records, mapping, warnings = [], None, []
try:
sheet = book.worksheets[0]
for row_index, row in enumerate(sheet.iter_rows(), 1):
values = [cell.value for cell in row]
if mapping is None:
found = {}
for index, value in enumerate(values):
key = name_key(str(value or ''))
for field, aliases in HEADERS.items():
if key in aliases:
found[field] = index
if {'date', 'name', 'amount', 'category'} <= found.keys():
mapping = found
continue
def value(field):
index = mapping.get(field)
return values[index] if index is not None and index < len(values) else None
if all(value(field) in (None, '') for field in ('date', 'name', 'amount', 'category')):
continue
# A summary/header row is not a transaction.
if value('date') in (None, '') and name_key(str(value('name') or '')) in {'', 'gesamt', 'summe'}:
continue
try:
if any(row[mapping[field]].data_type == 'f' for field in ('date', 'name', 'amount', 'category')):
raise ValueError('Formel innerhalb einer Buchung; bitte als echte Buchungswerte bereitstellen.')
raw_date = value('date')
if isinstance(raw_date, (int, float)):
raw_date = from_excel(raw_date, book.epoch)
if isinstance(raw_date, datetime):
raw_date = raw_date.date()
if isinstance(raw_date, date):
day = raw_date.isoformat()
else:
text = str(raw_date).strip()
try:
day = valid_date(text)
except ValueError:
day = datetime.strptime(text, '%d.%m.%Y').date().isoformat()
if value('name') is None:
raise ValueError('Position fehlt.')
name = canonical_name(value('name'))
category = CATEGORY_MAP.get(name_key(str(value('category') or '')))
if category is None:
raise ValueError('Unbekannte Kategorie.')
note = str(value('note') or '').strip()
# airBaltic is a bond: historical combined dividend label is inaccurate.
if name == 'airBaltic' and category != 'interest':
category = 'interest'
note = (note + ' | ' if note else '') + 'Excel-Kategorie fachlich korrigiert: airBaltic-Anleihezinsen.'
warnings.append(f'Zeile {row_index}: airBaltic als Zinsen normalisiert.')
raw_amount = value('amount')
if isinstance(raw_amount, (int, float)):
decimal = Decimal(str(raw_amount))
rounded = decimal.quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
if abs(decimal - rounded) > Decimal('0.000001'):
raise ValueError('Betrag hat mehr als zwei Nachkommastellen.')
amount = cents(format(rounded, '.2f'))
else:
amount = cents(raw_amount)
expected = boolean(value('expected'), 0)
received = boolean(value('received'), 1)
if len(note) > 2000:
raise ValueError('Notiz zu lang.')
kind = 'bond' if name == 'airBaltic' else 'etf' if name == 'STOXX Global Select Dividend 100' else 'interest' if category == 'interest' else 'stock' if category in {'dividend','distribution'} else 'other'
records.append(dict(date=day, name=name, amount=amount, category=category, note=note,
expected=expected, received=received, kind=kind))
except (ValueError, TypeError, OverflowError, ArithmeticError) as error:
raise ValueError(f'Zeile {row_index}: {error}') from error
if mapping is None:
raise ValueError('Kein Ertragsbuch mit Datum, Position/Art des Ertrags, Betrag und Kategorie im ersten Blatt gefunden.')
if not records:
raise ValueError('Das Ertragsbuch enthält keine Buchungen.')
return records, warnings
finally:
book.close()
def import_excel(filename, path=None, dry_run=False):
records, warnings = read_ledger(filename)
initialize(path)
added = skipped = 0
occurrences = Counter()
with connect(path) as db:
db.execute('BEGIN IMMEDIATE')
for record in records:
asset_id = ensure_asset(db, record['name'], record['kind'])
identity = (record['date'], asset_id, record['category'], record['amount'], record['expected'], record['received'])
occurrences[identity] += 1
occurrence = occurrences[identity]
fingerprint = hashlib.sha256(json.dumps([*identity, occurrence], separators=(',', ':')).encode()).hexdigest()
if db.execute('SELECT 1 FROM import_records WHERE fingerprint=?', (fingerprint,)).fetchone():
skipped += 1
continue
# Reuse matching manual entries as well. Preserve legitimate identical payments by occurrence.
existing = db.execute('SELECT id FROM income_entries WHERE date=? AND asset_id=? AND category=? AND amount=? AND expected=? AND received=? ORDER BY id LIMIT 1 OFFSET ?', (*identity, occurrence-1)).fetchone()
if existing:
entry_id = existing['id']
skipped += 1
else:
entry_id = db.execute('INSERT INTO income_entries (date,asset_id,category,amount,expected,received,note) VALUES (?,?,?,?,?,?,?)', (*identity, record['note'] or None)).lastrowid
added += 1
db.execute('INSERT INTO import_records (fingerprint,entry_id) VALUES (?,?)', (fingerprint, entry_id))
september = db.execute("SELECT COALESCE(SUM(amount),0) FROM income_entries WHERE date >= '2026-09-01' AND date < '2026-10-01' AND received=1").fetchone()[0]
enbridge = db.execute("SELECT COUNT(*) FROM income_entries i JOIN assets a ON a.id=i.asset_id WHERE date='2026-09-02' AND a.normalized_name='enbridge' AND amount=4 AND received=1").fetchone()[0]
if dry_run:
db.rollback()
return dict(rows=len(records), added=added, skipped=skipped, warnings=warnings,
september_2026=september, enbridge_check=bool(enbridge), dry_run=dry_run)
+107
View File
@@ -0,0 +1,107 @@
from datetime import date
from fastapi import HTTPException
from database import connect
from models import CATEGORIES, MONTHS, cents, valid_date, percent
def assets():
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:
raise HTTPException(404, 'Zahlung nicht gefunden.')
with connect() as db:
row = db.execute('SELECT * FROM income_entries WHERE 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.')
day = valid_date(data.get('date', ''))
amount = cents(data.get('amount', ''))
category = data.get('category', '')
if category not in CATEGORIES:
raise ValueError('Bitte eine gültige Kategorie auswählen.')
try:
asset_id = int(data.get('asset_id', ''))
if not 1 <= asset_id <= 9223372036854775807:
raise ValueError
except (TypeError, ValueError):
raise ValueError('Bitte eine Position auswählen.') from None
note = data.get('note', '').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')
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:
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
def list_entries(year=None, month=None, asset_id=None, category=None, limit=None, offset=0):
# 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)]:
if value is not None:
clauses.append(sql)
args.append(value)
query = 'SELECT i.*, a.name FROM income_entries i JOIN assets a ON a.id=i.asset_id'
if clauses:
query += ' WHERE ' + ' AND '.join(clauses)
query += ' ORDER BY i.date DESC, i.id DESC'
if limit is not None:
query += ' LIMIT ? OFFSET ?'
args.extend([limit, offset])
with connect() as db:
return db.execute(query, args).fetchall()
def available_years():
with connect() as db:
return [int(row[0]) for row in db.execute('SELECT DISTINCT substr(date,1,4) FROM income_entries ORDER BY 1')]
def dashboard(today=None):
today = today or date.today()
with connect() as db:
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')]
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
for row in grouped:
year = int(row['year'])
monthly[year][int(row['month']) - 1] = row['amount']
if year == today.year:
count += row['count']
totals = {year: sum(values) for year, values in monthly.items()}
current_month = monthly[today.year][today.month - 1]
prior_month = monthly.get(today.year - 1, [0] * 12)[today.month - 1]
prior_year = totals.get(today.year - 1, 0)
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,
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)}]})
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;--bg:#0b111c;--panel:#141e2c;--border:#293548;--text:#e7edf5;--muted:#a4b3c7;--green:#72e2b0;--red:#ff9696;--yellow:#f4c272}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}a{color:var(--green);text-decoration:none}a:hover{text-decoration:underline}button,input,select,textarea{font:inherit}button,.button{background:var(--green);color:#10241d;border:1px solid transparent;border-radius:9px;padding:11px 17px;cursor:pointer;font-weight:650;display:inline-block;text-align:center}button:hover,.button:hover{background:#98edc7;text-decoration:none}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid var(--green);outline-offset:3px}.topbar{border-bottom:1px solid var(--border);padding:20px max(24px,calc((100vw - 1440px)/2));display:flex;justify-content:space-between;gap:20px;align-items:center}.brand{font-size:20px;font-weight:700;color:var(--text)}nav{display:flex;gap:24px;flex-wrap:wrap}main{max-width:1488px;margin:auto;padding:32px 24px}h1{font-size:clamp(25px,4vw,34px);line-height:1.2;letter-spacing:-.03em;margin:8px 0 12px}h2{font-size:18px;line-height:1.4;margin:0 0 10px}p{margin:8px 0 18px}.eyebrow{font-size:12px;letter-spacing:.14em;font-weight:700;color:var(--green);margin-bottom:8px}.page-heading,.section-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:24px}.muted,small{color:var(--muted)}.positive{color:var(--green)}.negative{color:var(--red)}.warning{color:var(--yellow)}.kpi-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:24px}.kpi,.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:24px}.kpi h2{font-size:13px;font-weight:500;color:var(--muted)}.kpi strong{display:block;font-size:clamp(23px,2.5vw,30px);font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.kpi small{display:block;margin-top:9px;font-size:12px}.panel{margin-bottom:24px;min-width:0}.table-scroll{overflow:auto;max-width:100%}table{width:100%;border-collapse:collapse;text-align:left;font-size:14px}th,td{padding:13px 12px;border-bottom:1px solid var(--border)}th{color:var(--muted);font-weight:600}tbody tr:hover{background:#ffffff03}.numeric,.comparison td,.comparison th:not(:first-child){text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}.comparison th:first-child{position:sticky;left:0;background:var(--panel)}tfoot{font-weight:700}.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}.chart-wide{grid-column:1/-1}.chart{position:relative;height:300px}.amount-list{list-style:none;padding:0}.amount-list li{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--border);padding:8px 0}.amount-list strong{white-space:nowrap}.notice{padding:16px 20px;border:1px solid currentColor;border-radius:10px;margin-bottom:24px}.success{color:var(--green)}.form-panel{max-width:650px;margin:0 auto 24px}.entry-form{display:grid;gap:18px}label{display:grid;gap:6px;font-size:14px}input,select,textarea{width:100%;padding:11px 12px;border:1px solid #43516a;border-radius:8px;background:#0c1420;color:var(--text);min-width:0}textarea{resize:vertical}.checkbox{display:flex;align-items:center;gap:12px}.checkbox input{width:18px;height:18px;accent-color:var(--green)}.actions{display:flex;align-items:center;gap:14px}.actions form{margin:0}.danger{color:var(--red);background:transparent;border-color:#754343}.danger:hover{background:#45272d}.small{font-size:13px;padding:5px 9px}.nowrap,.badge{white-space:nowrap}.badge{font-size:12px}.note{min-width:120px;max-width:300px;overflow-wrap:anywhere;white-space:pre-wrap}.empty{text-align:center;color:var(--muted);padding:35px}.filters{display:flex;align-items:end;flex-wrap:wrap;gap:16px;margin-bottom:24px}.filters label{flex:1;min-width:140px}.pagination{display:flex;justify-content:center;gap:24px;flex-wrap:wrap;margin-top:22px;color:var(--muted)}footer{max-width:1488px;padding:0 24px 24px;margin:auto;color:var(--muted);font-size:12px;display:flex;justify-content:space-between;gap:16px}summary{cursor:pointer;color:var(--green);margin-top:15px}@media(max-width:1000px){.kpi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:650px){.topbar{align-items:flex-start;flex-direction:column;padding:18px}nav{gap:18px;font-size:14px}main{padding:24px 14px}.page-heading,.section-heading{align-items:flex-start;flex-direction:column}.page-heading .button{width:100%}.kpi-grid{gap:10px}.kpi{padding:16px 12px}.kpi strong{font-size:23px}.panel{padding:18px 12px}.chart-grid{grid-template-columns:1fr}.chart{height:280px}.section-heading{gap:5px}footer{flex-direction:column}.filters{gap:12px}.actions{gap:10px}.comparison th:first-child{min-width:100px}}
+39
View File
@@ -0,0 +1,39 @@
'use strict';
(() => {
const status = document.getElementById('chart-status');
if (typeof Chart === 'undefined') {
status.textContent = 'Chart.js konnte nicht vom CDN geladen werden. Alle Werte bleiben in den Tabellen verfügbar.';
return;
}
const data = JSON.parse(document.getElementById('chart-data').textContent);
const euro = cents => new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(cents / 100);
const palette = ['#72e2b0','#79b6ff','#f4c272','#bc9cfa','#f691aa','#67cbd0','#e5db89','#9ec28b','#b2bdec','#daaa81'];
Chart.defaults.color = '#a4b3c7';
Chart.defaults.borderColor = '#293548';
Chart.defaults.font.family = 'system-ui, sans-serif';
new Chart(document.getElementById('monthly-chart'), {
type: 'line',
data: {labels: data.months, datasets: data.years.map((year, i) => ({...year, borderColor: palette[i % palette.length], backgroundColor: palette[i % palette.length], tension: 0.2, pointRadius: 3}))},
options: {responsive:true, maintainAspectRatio:false, animation:false, interaction:{mode:'index',intersect:false},
scales:{y:{ticks:{callback:euro},title:{display:true,text:'Euro'}}}, plugins:{tooltip:{callbacks:{label:ctx => `${ctx.dataset.label}: ${euro(ctx.raw)}`}}}}
});
function shares(id, rows) {
// Signed corrections cannot be represented truthfully as pie slices.
const negative = rows.some(row => row.amount < 0);
const total = rows.reduce((sum,row) => sum + row.amount, 0);
new Chart(document.getElementById(id), {
type: negative ? 'bar' : 'doughnut',
data:{labels:rows.map(row => row.name),datasets:[{data:rows.map(row => row.amount),backgroundColor:rows.map((_,i) => palette[i % palette.length]),borderWidth:0}]},
options:{responsive:true,maintainAspectRatio:false,animation:false,
...(negative ? {scales:{y:{ticks:{callback:euro}}}} : {cutout:'68%'}),
plugins:{legend:{display:!negative,position:'bottom',labels:{boxWidth:10,font:{size:11}}},tooltip:{callbacks:{label:ctx => {
const amount = rows[ctx.dataIndex].amount;
const share = total === 0 ? '' : new Intl.NumberFormat('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2}).format(amount / total * 100) + ' %';
return `${ctx.label}: ${euro(amount)} (${share})`;
}}}}}
});
}
shares('shares-chart', data.shares);
shares('kinds-chart', data.kinds);
status.textContent = data.years.some(year => year.data.some(value => value !== 0)) ? 'Diagramme zeigen tatsächlich erhaltene Einnahmen. Negative Positionssummen werden als Balken dargestellt.' : 'Noch keine tatsächlichen Einnahmen vorhanden.';
})();
+10
View File
@@ -0,0 +1,10 @@
'use strict';
document.querySelectorAll('form[data-confirm]').forEach(form => {
form.addEventListener('submit', event => {
if (!window.confirm(form.dataset.confirm)) event.preventDefault();
});
});
const filters = document.getElementById('income-filters');
if (filters) filters.addEventListener('submit', () => {
filters.querySelectorAll('select').forEach(select => { if (!select.value) select.disabled = true; });
});
+9
View File
@@ -0,0 +1,9 @@
<div class="table-scroll"><table>
<thead><tr><th>Datum</th><th>Position</th><th>Kategorie</th><th class="numeric">Betrag</th><th>Status</th><th>Notiz</th><th>Aktionen</th></tr></thead>
<tbody>{% for entry in entries %}<tr>
<td class="nowrap">{{ entry.date[8:10] }}.{{ entry.date[5:7] }}.{{ entry.date[:4] }}</td><td>{{ entry.name }}</td><td>{{ categories[entry.category] }}</td>
<td class="numeric {{ 'negative' if entry.amount < 0 else '' }}">{{ entry.amount|money }}</td>
<td><span class="badge {{ 'positive' if entry.received else 'warning' }}">{{ 'Erhalten' if entry.received else ('Erwartet / offen' if entry.expected else 'Nicht erhalten') }}</span></td>
<td class="note">{{ entry.note or '' }}</td><td><div class="actions"><a href="/income/{{ entry.id }}/edit">Bearbeiten</a><form method="post" action="/income/{{ entry.id }}/delete" data-confirm="Diese Zahlung wirklich löschen?"><button class="danger small" type="submit">Löschen</button></form></div></td>
</tr>{% else %}<tr><td colspan="7" class="empty">Noch keine Zahlungen vorhanden. Trage eine Zahlung ein oder importiere deine Excel-Historie.</td></tr>{% endfor %}</tbody>
</table></div>
+3
View File
@@ -0,0 +1,3 @@
{% extends 'base.html' %}
{% block content %}<section class="panel form-panel"><h1>Neue Position</h1>{% if error %}<p class="notice negative" role="alert">{{ error }}</p>{% endif %}
<form method="post" class="entry-form"><label>Name<input name="name" required maxlength="150" value="{{ data.get('name', '') }}"></label><label>Ticker (optional)<input name="ticker" maxlength="30" value="{{ data.get('ticker', '') }}"></label><label>Positionstyp<select name="asset_type">{% for key,label in asset_types.items() %}<option value="{{ key }}" {{ 'selected' if data.get('asset_type') == key else '' }}>{{ label }}</option>{% endfor %}</select></label><p class="muted">Bekannte Namen werden zusammengeführt. MSC steht für Main Street Capital.</p><div class="actions"><button type="submit">Position speichern</button><a href="/income/new">Zurück</a></div></form></section>{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark"><title>{% block title %}Finance Dashboard{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='css/style.css') }}">
<script defer src="{{ url_for('static', path='js/forms.js') }}"></script>
</head>
<body>
<header class="topbar"><a class="brand" href="/">💶 Finance Dashboard</a><nav aria-label="Hauptnavigation"><a href="/">Übersicht</a><a href="/income">Alle Zahlungen</a><a href="/export/income.csv">CSV-Export</a></nav></header>
<main>
{% set message = request.query_params.get('message') %}
{% if message in ['saved', 'deleted'] %}<p class="notice success" role="status">{{ 'Zahlung gespeichert.' if message == 'saved' else 'Zahlung gelöscht.' }}</p>{% endif %}
{% block content %}{% endblock %}
</main>
<footer>pinguAurora meldet sich zum Dienst. <span>Private Finanzen · Beträge in EUR</span></footer>
{% block scripts %}{% endblock %}
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{% extends 'base.html' %}
{% block content %}<div class="page-heading"><div><p class="eyebrow">ERTRAGSBUCH</p><h1>Alle Zahlungen</h1></div><a class="button" href="/income/new">+ Zahlung eintragen</a></div>
<section class="panel"><form method="get" class="filters" id="income-filters">
<label>Jahr<select name="year"><option value="">Alle Jahre</option>{% for year in years %}<option value="{{ year }}" {{ 'selected' if filters.year == year else '' }}>{{ year }}</option>{% endfor %}</select></label>
<label>Monat<select name="month"><option value="">Alle Monate</option>{% for month in months %}<option value="{{ loop.index }}" {{ 'selected' if filters.month == loop.index else '' }}>{{ month }}</option>{% endfor %}</select></label>
<label>Position<select name="asset_id"><option value="">Alle Positionen</option>{% for asset in assets %}<option value="{{ asset.id }}" {{ 'selected' if filters.asset_id == asset.id else '' }}>{{ asset.name }}</option>{% endfor %}</select></label>
<label>Kategorie<select name="category"><option value="">Alle Kategorien</option>{% for key,label in categories.items() %}<option value="{{ key }}" {{ 'selected' if filters.category == key else '' }}>{{ label }}</option>{% endfor %}</select></label>
<button type="submit">Filtern</button><a href="/income">Zurücksetzen</a></form>
{% include '_entries.html' %}<div class="pagination">{% if page > 1 %}<a href="{{ request.url.include_query_params(page=page-1) }}">← Zurück</a>{% endif %}<span>Seite {{ page }} · bis zu 100 Zahlungen pro Seite</span>{% if more %}<a href="{{ request.url.include_query_params(page=page+1) }}">Weiter →</a>{% endif %}</div></section>{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block title %}Zahlung {{ 'bearbeiten' if entry_id else 'eintragen' }} · Finance Dashboard{% endblock %}
{% block content %}
<section class="form-panel panel"><p class="eyebrow">ERTRAGSBUCH</p><h1>Zahlung {{ 'bearbeiten' if entry_id else 'eintragen' }}</h1>
{% if error %}<p role="alert" class="notice negative">{{ error }}</p>{% endif %}
<form method="post" class="entry-form">
<label>Datum<input required type="date" name="date" value="{{ data.get('date', '') }}"></label>
<label>Position<select name="asset_id" required><option value="">Bitte auswählen</option>{% for asset in assets %}{% if asset.active or asset.id|string == data.get('asset_id')|string %}<option value="{{ asset.id }}" {{ 'selected' if asset.id|string == data.get('asset_id')|string else '' }}>{{ asset.name }}{{ ' (inaktiv)' if not asset.active else '' }}</option>{% endif %}{% endfor %}</select></label>
<a href="/assets/new">+ Neue Position</a>
<label>Kategorie<select name="category" required>{% for key, label in categories.items() %}<option value="{{ key }}" {{ 'selected' if data.get('category') == key else '' }}>{{ label }}</option>{% endfor %}</select></label>
<label>Betrag in Euro<input required type="text" name="amount" inputmode="decimal" maxlength="14" placeholder="0,04" value="{{ data.get('amount', '') }}"><small>Komma oder Punkt, maximal zwei Nachkommastellen. Negative Beträge für Korrekturen.</small></label>
<label>Notiz (optional)<textarea name="note" rows="3" maxlength="2000">{{ data.get('note') or '' }}</textarea></label>
<label class="checkbox"><input type="checkbox" name="expected" value="1" {{ 'checked' if data.get('expected') else '' }}>Erwartete Zahlung</label>
<label class="checkbox"><input type="checkbox" name="received" value="1" {{ 'checked' if data.get('received') else '' }}>Tatsächlich erhalten</label>
<p class="muted">Nur „Tatsächlich erhalten“ fließt in die Auswertung ein. Bei offenen oder ausgefallenen Zahlungen dieses Häkchen entfernen.</p>
<div class="actions"><button type="submit">Zahlung speichern</button><a href="/">Abbrechen</a></div>
</form></section>
{% endblock %}
+28 -57
View File
@@ -1,57 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Finance Dashboard</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: 24px;
background: #0c1220;
color: #e8edf5;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.6;
}
.card {
width: 100%;
max-width: 620px;
padding: clamp(24px, 6vw, 48px);
border: 1px solid #2a3549;
border-radius: 20px;
background: #151e2e;
box-shadow: 0 20px 60px #00000040;
}
h1 {
margin: 0 0 24px;
font-size: clamp(1.5rem, 5vw, 2.25rem);
line-height: 1.25;
letter-spacing: -0.03em;
}
.status {
margin: 0 0 16px;
color: #86efac;
font-weight: 600;
}
.description { margin: 0; color: #b7c3d5; }
</style>
</head>
<body>
<main class="card">
<h1>💶 Finance Dashboard</h1>
<p class="status">pinguAurora meldet sich zum Dienst.</p>
<p class="description">Hier entsteht dein persönliches Dashboard für Dividenden, Zinsen, Depot und Notgroschen.</p>
</main>
</body>
</html>
{% extends 'base.html' %}
{% macro delta(value) %}<span class="{{ 'positive' if value is not none and value > 0 else 'negative' if value is not none and value < 0 else 'muted' }}">{{ value|percent }}</span>{% endmacro %}
{% block content %}
<section class="page-heading"><div><p class="eyebrow">PASSIVES EINKOMMEN</p><h1>Deine Erträge im Überblick</h1><p class="muted">Dividenden, Ausschüttungen und Zinsen · Stand {{ stats.today.strftime('%d.%m.%Y') }}</p></div><a class="button" href="/income/new">+ Zahlung eintragen</a></section>
<div class="kpi-grid">
<article class="kpi"><h2>{{ months[stats.today.month-1] }} {{ stats.today.year }}</h2><strong>{{ stats.month|money }}</strong><small>Passives Einkommen aktueller Monat</small></article>
<article class="kpi"><h2>Gleicher Monat Vorjahr</h2><strong>{{ stats.prior_month|money }}</strong><small>{{ months[stats.today.month-1] }} {{ stats.today.year-1 }}</small></article>
<article class="kpi"><h2>Monat zum Vorjahr</h2><strong>{{ delta(stats.month_change) }}</strong><small>Veränderung zum Vorjahresmonat</small></article>
<article class="kpi"><h2>Aktuelles Jahr {{ stats.today.year }}</h2><strong>{{ stats.year|money }}</strong><small>Tatsächlich erhaltene Zahlungen</small></article>
<article class="kpi"><h2>Vorjahr {{ stats.today.year-1 }}</h2><strong>{{ stats.prior_year|money }}</strong><small>Gesamtes Kalenderjahr</small></article>
<article class="kpi"><h2>Jahr zum Vorjahr</h2><strong>{{ delta(stats.year_change) }}</strong><small>Aktuelles Jahr gegen gesamtes Vorjahr</small></article>
<article class="kpi"><h2>Alle Jahre</h2><strong class="positive">{{ stats.all_time|money }}</strong><small>Passives Einkommen insgesamt</small></article>
<article class="kpi"><h2>Zahlungen {{ stats.today.year }}</h2><strong>{{ stats.count }}</strong><small>Anzahl tatsächlich erhaltener Zahlungen</small></article>
</div>
{% if stats.pending.count %}<aside class="notice warning">{{ stats.pending.count }} erwartete, nicht erhaltene Zahlungen: <strong>{{ stats.pending.amount|money }}</strong>. Diese Beträge sind nicht in den tatsächlichen Einnahmen enthalten. <a href="/income">Buchungen ansehen</a></aside>{% endif %}
<section class="panel"><div class="section-heading"><h2>Jahresvergleich</h2><span class="muted">Nur tatsächlich erhaltene Zahlungen</span></div>
<div class="table-scroll"><table class="comparison"><thead><tr><th>Monat</th>{% for year in stats.years %}<th>{{ year }}</th>{% if not loop.first %}<th>{{ year }} vs. {{ year-1 }}</th>{% endif %}{% endfor %}<th>Alle Jahre</th></tr></thead>
<tbody>{% for month in months %}{% set mi = loop.index0 %}<tr><th>{{ month }}</th>{% for year in stats.years %}<td>{{ stats.monthly[year][mi]|money }}</td>{% if not loop.first %}<td>{{ delta(compare(stats.monthly[year][mi], stats.monthly[year-1][mi])) }}</td>{% endif %}{% endfor %}<td>{{ stats.monthly.values()|map(attribute=mi)|sum|money }}</td></tr>{% endfor %}</tbody>
<tfoot><tr><th>Gesamt</th>{% for year in stats.years %}<td>{{ stats.totals[year]|money }}</td>{% if not loop.first %}<td>{{ delta(compare(stats.totals[year], stats.totals[year-1])) }}</td>{% endif %}{% endfor %}<td>{{ stats.all_time|money }}</td></tr></tfoot></table></div></section>
<div class="chart-grid">
<section class="panel chart-wide"><h2>Monatsverlauf</h2><p class="muted">Die Jahre im direkten Vergleich</p><div class="chart"><canvas id="monthly-chart" role="img" aria-label="Monatseinnahmen je Jahr; Zahlen in der Jahresvergleichstabelle"></canvas></div></section>
<section class="panel"><h2>Einkommensanteile</h2><p class="muted">Positionen · alle Jahre</p><div class="chart"><canvas id="shares-chart" role="img" aria-label="Anteile nach Position"></canvas></div><details><summary>Beträge nach Position</summary><ul class="amount-list">{% for row in stats.shares %}<li><span>{{ row.name }}</span><strong>{{ row.amount|money }}</strong></li>{% else %}<li>Noch keine Einnahmen.</li>{% endfor %}</ul></details></section>
<section class="panel"><h2>Einkommensarten</h2><p class="muted">Alle Jahre</p><div class="chart"><canvas id="kinds-chart" role="img" aria-label="Anteile nach Einkommensart"></canvas></div><details><summary>Beträge nach Einkommensart</summary><ul class="amount-list">{% for row in stats.chart.kinds %}<li><span>{{ row.name }}</span><strong>{{ row.amount|money }}</strong></li>{% endfor %}</ul></details></section>
</div>
<p class="muted" id="chart-status" role="status">Diagramme werden geladen. Alle Beträge sind auch als Tabellen verfügbar.</p>
<section class="panel"><div class="section-heading"><h2>Letzte Zahlungen</h2><a href="/income">Alle Zahlungen →</a></div>{% include '_entries.html' %}</section>
{% endblock %}
{% block scripts %}<script id="chart-data" type="application/json">{{ stats.chart|tojson }}</script><script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script><script defer src="{{ url_for('static', path='js/dashboard.js') }}"></script>{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from fastapi.templating import Jinja2Templates
from models import money, percent, percent_text, CATEGORIES, ASSET_TYPES, MONTHS
templates = Jinja2Templates(directory=str(Path(__file__).parent / 'templates'))
templates.env.filters.update(money=money, percent=percent_text)
templates.env.globals.update(compare=percent, categories=CATEGORIES, asset_types=ASSET_TYPES, months=MONTHS)
def render(request, name, context=None, status_code=200):
return templates.TemplateResponse(request=request, name=name, context=context or {}, status_code=status_code)
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
cd -- "$(dirname -- "$(readlink -f -- "$0")")"
git pull --ff-only
DOCKER_BUILDKIT=0 docker build \
-t finance-dashboard-finance-dashboard:latest \
.
docker compose up -d --no-build
docker compose ps
curl --fail --silent --show-error --retry 10 --retry-connrefused --retry-delay 2 --max-time 5 http://127.0.0.1:8081/health
printf '\n'
+2
View File
@@ -0,0 +1,2 @@
-r requirements-import.txt
httpx
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
openpyxl
+1
View File
@@ -1,3 +1,4 @@
fastapi
uvicorn[standard]
jinja2
python-multipart
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Usage: python scripts/import_excel.py workbook.xlsx [--db ./data/finance.db]."""
import argparse
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'app'))
from models import money
from services.excel_import import import_excel
def main():
parser = argparse.ArgumentParser(description='Ertragsbuch aus dem ersten XLSX-Blatt nach SQLite importieren.')
parser.add_argument('filename', type=Path)
parser.add_argument('--db', type=Path, help='Alternativer Datenbankpfad, Standard: FINANCE_DB_PATH oder /data/finance.db')
parser.add_argument('--dry-run', action='store_true', help='Transaktion zurückrollen; Datenbank/Seed werden bei Bedarf angelegt.')
args = parser.parse_args()
try:
result = import_excel(args.filename, args.db, args.dry_run)
except ImportError:
parser.exit(1, 'Importbibliothek fehlt: pip install -r requirements-import.txt\n')
except Exception as error:
parser.exit(1, f'Import abgebrochen: {error}\n')
print(f"{'Vorschau' if result['dry_run'] else 'Import'}: {result['rows']} Buchungen, {result['added']} neu, {result['skipped']} bereits vorhanden.")
for warning in result['warnings']:
print(warning)
print(f"Plausibilitätscheck September 2026: {money(result['september_2026'])} (Referenz 3,71 €).")
print(f"Enbridge 02.09.2026 / 0,04 €: {'gefunden' if result['enbridge_check'] else 'nicht gefunden'}.")
if result['september_2026'] != 371:
print('Hinweis: September-Summe weicht vom historischen Referenzstand ab; Buchungen prüfen.')
if __name__ == '__main__':
main()
+191
View File
@@ -0,0 +1,191 @@
import csv
from datetime import date
from decimal import Decimal
import io
import os
from pathlib import Path
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 openpyxl import Workbook
from database import connect
from main import app
from models import cents, percent
from services.excel_import import import_excel
from services.income_service import dashboard
class FinanceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.path = Path(self.temp.name) / 'finance.db'
self.env = patch.dict(os.environ, {'FINANCE_DB_PATH': str(self.path)})
self.env.start()
self.client = TestClient(app)
self.client.__enter__()
with connect() as db:
self.asset = db.execute("SELECT id FROM assets WHERE name='Main Street Capital'").fetchone()[0]
def tearDown(self):
self.client.__exit__(None, None, None)
self.env.stop()
self.temp.cleanup()
def payment(self, **changes):
data = dict(date='2026-09-02', asset_id=str(self.asset), category='dividend', amount='0,04', note='', received='1')
data.update(changes)
response = self.client.post('/income/new', data=data, follow_redirects=False)
self.assertEqual(response.status_code, 303, response.text)
return response
def test_health_and_empty_dashboard(self):
self.assertEqual(self.client.get('/health').json(), {'status': 'ok'})
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
self.assertIn('Noch keine Zahlungen', response.text)
self.assertIn('0,00 €', response.text)
def test_create_edit_delete_and_validation(self):
self.payment(note='<script>alert(1)</script>')
self.assertIn('&lt;script&gt;', self.client.get('/income').text)
with connect() as db:
row = db.execute('SELECT * FROM income_entries').fetchone()
self.assertEqual(row['amount'], 4)
entry_id = row['id']
self.assertEqual(self.client.get(f'/income/{entry_id}/edit').status_code, 200)
response = self.client.post(f'/income/{entry_id}/edit', data=dict(date='2026-09-03', asset_id=self.asset, category='interest', amount='28.00', received='1'))
self.assertEqual(response.status_code, 200)
with connect() as db:
self.assertEqual(db.execute('SELECT amount FROM income_entries').fetchone()[0], 2800)
with self.assertRaises(sqlite3.IntegrityError):
db.execute('DELETE FROM assets WHERE id=?', (self.asset,))
for changes in [dict(amount='NaN'), dict(amount='1.234'), dict(date='2026-02-30'), dict(asset_id='99999'), dict(asset_id='9'*30), dict(category='invalid'), dict(amount='999999999999999')]:
data = dict(date='2026-09-02', asset_id=self.asset, category='dividend', amount='0,04', received='1')
data.update(changes)
self.assertEqual(self.client.post('/income/new', data=data).status_code, 422)
self.assertEqual(self.client.post(f'/income/{entry_id}/delete', follow_redirects=False).status_code, 303)
self.assertEqual(self.client.get(f'/income/{entry_id}/edit').status_code, 404)
def test_month_year_and_comparison(self):
self.payment(date='2025-09-02', amount='10')
self.payment(date='2025-01-02', amount='5')
self.payment(amount='12')
self.payment(amount='3')
self.payment(date='2026-01-02', amount='15')
self.payment(amount='28', expected='1', received='')
stats = dashboard(date(2026,9,9))
self.assertEqual(stats['month'], 1500)
self.assertEqual(stats['prior_month'], 1000)
self.assertEqual(stats['year'], 3000)
self.assertEqual(stats['prior_year'], 1500)
self.assertEqual(stats['month_change'], Decimal('50.00'))
self.assertEqual(stats['year_change'], Decimal('100.00'))
self.assertEqual(stats['all_time'], 4500)
self.assertEqual(stats['count'], 3)
self.assertEqual(stats['pending']['amount'], 2800)
self.assertEqual(self.client.get('/').status_code, 200)
def test_zero_and_decimal_and_dynamic_years(self):
self.assertIsNone(percent(100, 0))
self.assertEqual(percent(50,100), Decimal('-50.00'))
for value in ['0.04', '0,04']:
self.assertEqual(cents(value),4)
self.payment(date='2028-01-01')
stats = dashboard(date(2026,9,9))
self.assertIn(2028, stats['years'])
self.assertIn('2028 vs. 2027', self.client.get('/').text)
self.assertIsNone(stats['year_change'])
def test_csv_filters_and_security(self):
self.payment(note='=HYPERLINK("bad")')
self.payment(date='2025-03-01', category='interest', received='', expected='1')
response = self.client.get('/export/income.csv')
self.assertEqual(response.status_code,200)
self.assertTrue(response.content.startswith(b'\xef\xbb\xbf'))
rows = list(csv.reader(io.StringIO(response.content.decode('utf-8-sig')), delimiter=';'))
self.assertEqual(rows[0], ['Datum','Position','Kategorie','Betrag','Notiz','Erwartet','Erhalten'])
self.assertEqual(rows[1][3], '0,04')
self.assertTrue(rows[1][4].startswith("'="))
self.assertEqual(rows[2][-2:], ['Ja','Nein'])
self.assertEqual(self.client.get('/income?year=&month=&asset_id=&category=').status_code,200)
self.assertEqual(self.client.get('/income?month=13').status_code,422)
response = self.client.get('/income?year=2026&month=9&category=dividend')
self.assertIn('02.09.2026',response.text)
self.assertNotIn('01.03.2025',response.text)
self.assertEqual(self.client.post('/income/1/delete', headers={'Origin':'https://evil.example'}).status_code,403)
def workbook(self, invalid=False):
book = Workbook()
sheet = book.active
sheet.append(['Dashboard', 'Gesamt', '=SUM(C4:C9)'])
sheet.append(['Datum','Art des Ertrags','Betrag (€)','Kategorie','Erwartet','Erhalten'])
sheet.append([date(2026,9,2),'MSC',0.04,'Dividenden / Ausschüttungen',None,None])
sheet.append([date(2026,9,2),'Main Street Capital',0.04,'dividend',None,None])
sheet.append([date(2026,8,1),'Air Baltic',28,'Zinsen',True,False])
sheet.append([date(2026,9,1),'Neue Position',1.25,'Sonstiges',None,None])
if invalid:
sheet.append(['unbekannt','MSC',4,'dividend'])
book.create_sheet('Ignorieren').append([date(2026,1,1),'MSC',999,'dividend'])
filename = Path(self.temp.name) / 'history.xlsx'
book.save(filename)
book.close()
return filename
def test_import_duplicate_occurrences_aliases_and_tombstones(self):
filename = self.workbook()
self.payment() # manual match must be reused
result = import_excel(filename)
self.assertEqual(result['rows'],4)
self.assertEqual(result['added'],3)
self.assertEqual(import_excel(filename)['added'],0)
with connect() as db:
self.assertEqual(db.execute('SELECT COUNT(*) FROM income_entries').fetchone()[0],4)
self.assertEqual(db.execute("SELECT COUNT(*) FROM assets WHERE normalized_name='mainstreetcapital'").fetchone()[0],1)
self.assertEqual(db.execute('SELECT SUM(amount) FROM income_entries WHERE received=1').fetchone()[0],133)
entry_id = db.execute('SELECT id FROM income_entries ORDER BY id LIMIT 1').fetchone()[0]
self.client.post(f'/income/{entry_id}/delete')
self.assertEqual(import_excel(filename)['added'],0)
with connect() as db:
self.assertEqual(db.execute('SELECT COUNT(*) FROM income_entries').fetchone()[0],3)
def test_import_atomic_and_dry_run(self):
filename = self.workbook()
self.assertEqual(import_excel(filename, dry_run=True)['added'],4)
with connect() as db:
self.assertEqual(db.execute('SELECT COUNT(*) FROM income_entries').fetchone()[0],0)
filename = self.workbook(invalid=True)
with self.assertRaises(ValueError):
import_excel(filename)
with connect() as db:
self.assertEqual(db.execute('SELECT COUNT(*) FROM income_entries').fetchone()[0],0)
def test_sqlite_lock_returns_retryable_error(self):
locker = sqlite3.connect(self.path)
try:
locker.execute('BEGIN IMMEDIATE')
response = self.client.post('/income/new', data=dict(date='2026-09-01', asset_id=self.asset,
category='dividend', amount='1', received='1'))
self.assertEqual(response.status_code, 503)
self.assertEqual(response.headers['retry-after'], '5')
finally:
locker.rollback()
locker.close()
with connect() as db:
self.assertEqual(db.execute('SELECT COUNT(*) FROM income_entries').fetchone()[0], 0)
def test_asset_normalization_and_inactive_asset(self):
response = self.client.post('/assets/new', data={'name':'MSC','asset_type':'stock'}, follow_redirects=False)
self.assertEqual(response.status_code,303)
self.assertEqual(response.headers['location'],f'/income/new?asset_id={self.asset}')
with connect() as db:
db.execute('UPDATE assets SET active=0 WHERE id=?',(self.asset,))
self.assertEqual(self.client.post('/income/new',data=dict(date='2026-09-01',asset_id=self.asset,category='dividend',amount='1')).status_code,422)
if __name__ == '__main__':
unittest.main()