Add SQLite passive income dashboard with Excel import and deployment tools
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
**/__pycache__
|
||||||
|
data
|
||||||
|
import
|
||||||
|
*.xlsx
|
||||||
|
.env
|
||||||
@@ -2,3 +2,9 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
|
.venv/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
import/
|
||||||
|
*.xlsx
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
@@ -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 import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from database import initialize
|
||||||
app = FastAPI(title="Finance Dashboard")
|
from routes import dashboard, income, export
|
||||||
templates = Jinja2Templates(directory="/app/templates")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@asynccontextmanager
|
||||||
async def index(request: Request):
|
async def lifespan(app):
|
||||||
return templates.TemplateResponse(request=request, name="index.html")
|
initialize()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
app = FastAPI(title='Finance Dashboard', lifespan=lifespan)
|
||||||
async def health():
|
app.mount('/static', StaticFiles(directory=Path(__file__).parent / 'static'), name='static')
|
||||||
return {"status": "ok"}
|
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'}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)})
|
||||||
@@ -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"'})
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)}]})
|
||||||
@@ -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}}
|
||||||
@@ -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.';
|
||||||
|
})();
|
||||||
@@ -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; });
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -1,57 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
{% extends 'base.html' %}
|
||||||
<html lang="de">
|
{% 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 %}
|
||||||
<head>
|
{% block content %}
|
||||||
<meta charset="utf-8">
|
<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>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<div class="kpi-grid">
|
||||||
<meta name="color-scheme" content="dark">
|
<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>
|
||||||
<title>Finance Dashboard</title>
|
<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>
|
||||||
<style>
|
<article class="kpi"><h2>Monat zum Vorjahr</h2><strong>{{ delta(stats.month_change) }}</strong><small>Veränderung zum Vorjahresmonat</small></article>
|
||||||
* { box-sizing: border-box; }
|
<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>
|
||||||
body {
|
<article class="kpi"><h2>Jahr zum Vorjahr</h2><strong>{{ delta(stats.year_change) }}</strong><small>Aktuelles Jahr gegen gesamtes Vorjahr</small></article>
|
||||||
margin: 0;
|
<article class="kpi"><h2>Alle Jahre</h2><strong class="positive">{{ stats.all_time|money }}</strong><small>Passives Einkommen insgesamt</small></article>
|
||||||
min-height: 100vh;
|
<article class="kpi"><h2>Zahlungen {{ stats.today.year }}</h2><strong>{{ stats.count }}</strong><small>Anzahl tatsächlich erhaltener Zahlungen</small></article>
|
||||||
min-height: 100svh;
|
</div>
|
||||||
display: grid;
|
{% 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 %}
|
||||||
place-items: center;
|
<section class="panel"><div class="section-heading"><h2>Jahresvergleich</h2><span class="muted">Nur tatsächlich erhaltene Zahlungen</span></div>
|
||||||
padding: 24px;
|
<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>
|
||||||
background: #0c1220;
|
<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>
|
||||||
color: #e8edf5;
|
<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>
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
<div class="chart-grid">
|
||||||
line-height: 1.6;
|
<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>
|
||||||
.card {
|
</div>
|
||||||
width: 100%;
|
<p class="muted" id="chart-status" role="status">Diagramme werden geladen. Alle Beträge sind auch als Tabellen verfügbar.</p>
|
||||||
max-width: 620px;
|
<section class="panel"><div class="section-heading"><h2>Letzte Zahlungen</h2><a href="/income">Alle Zahlungen →</a></div>{% include '_entries.html' %}</section>
|
||||||
padding: clamp(24px, 6vw, 48px);
|
{% endblock %}
|
||||||
border: 1px solid #2a3549;
|
{% 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 %}
|
||||||
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>
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements-import.txt
|
||||||
|
httpx
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
openpyxl
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
jinja2
|
jinja2
|
||||||
|
python-multipart
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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('<script>', 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()
|
||||||
Reference in New Issue
Block a user