{{ 'Zahlung gespeichert.' if message == 'saved' else 'Zahlung gelöscht.' }}
{% endif %} +{% block content %}{% endblock %} +diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..26e07e1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.agents +.codex +.venv +__pycache__ +**/__pycache__ +data +import +*.xlsx +.env diff --git a/.gitignore b/.gitignore index d51f033..de7d690 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ __pycache__/ *.pyc .env data/ +.venv/ +*.db +*.db-wal +*.db-shm +import/ +*.xlsx diff --git a/README.md b/README.md new file mode 100644 index 0000000..807620f --- /dev/null +++ b/README.md @@ -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). diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..0bb4a2c --- /dev/null +++ b/app/database.py @@ -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) diff --git a/app/main.py b/app/main.py index 34fdaa5..c41781d 100644 --- a/app/main.py +++ b/app/main.py @@ -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('
Bitte in einigen Sekunden erneut versuchen.
Zum Dashboard', status_code=503, headers={'Retry-After': '5'}) + + +@app.get('/health') +def health(): + return {'status': 'ok'} diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..b7358a3 --- /dev/null +++ b/app/models.py @@ -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) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..d46e5d6 --- /dev/null +++ b/app/routes/dashboard.py @@ -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)}) diff --git a/app/routes/export.py b/app/routes/export.py new file mode 100644 index 0000000..5105be9 --- /dev/null +++ b/app/routes/export.py @@ -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"'}) diff --git a/app/routes/income.py b/app/routes/income.py new file mode 100644 index 0000000..07bf368 --- /dev/null +++ b/app/routes/income.py @@ -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) diff --git a/app/services/excel_import.py b/app/services/excel_import.py new file mode 100644 index 0000000..adbcdea --- /dev/null +++ b/app/services/excel_import.py @@ -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) diff --git a/app/services/income_service.py b/app/services/income_service.py new file mode 100644 index 0000000..aaecab0 --- /dev/null +++ b/app/services/income_service.py @@ -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)}]}) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..659300f --- /dev/null +++ b/app/static/css/style.css @@ -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}} diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js new file mode 100644 index 0000000..8cab140 --- /dev/null +++ b/app/static/js/dashboard.js @@ -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.'; +})(); diff --git a/app/static/js/forms.js b/app/static/js/forms.js new file mode 100644 index 0000000..9cf5f55 --- /dev/null +++ b/app/static/js/forms.js @@ -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; }); +}); diff --git a/app/templates/_entries.html b/app/templates/_entries.html new file mode 100644 index 0000000..75e5053 --- /dev/null +++ b/app/templates/_entries.html @@ -0,0 +1,9 @@ +| Datum | Position | Kategorie | Betrag | Status | Notiz | Aktionen |
|---|---|---|---|---|---|---|
| {{ entry.date[8:10] }}.{{ entry.date[5:7] }}.{{ entry.date[:4] }} | {{ entry.name }} | {{ categories[entry.category] }} | +{{ entry.amount|money }} | +{{ 'Erhalten' if entry.received else ('Erwartet / offen' if entry.expected else 'Nicht erhalten') }} | +{{ entry.note or '–' }} | + |
| Noch keine Zahlungen vorhanden. Trage eine Zahlung ein oder importiere deine Excel-Historie. | ||||||
{{ error }}
{% endif %} +{{ 'Zahlung gespeichert.' if message == 'saved' else 'Zahlung gelöscht.' }}
{% endif %} +{% block content %}{% endblock %} +ERTRAGSBUCH
ERTRAGSBUCH
{{ error }}
{% endif %} +pinguAurora meldet sich zum Dienst.
-Hier entsteht dein persönliches Dashboard für Dividenden, Zinsen, Depot und Notgroschen.
-PASSIVES EINKOMMEN
Dividenden, Ausschüttungen und Zinsen · Stand {{ stats.today.strftime('%d.%m.%Y') }}
| Monat | {% for year in stats.years %}{{ year }} | {% if not loop.first %}{{ year }} vs. {{ year-1 }} | {% endif %}{% endfor %}Alle Jahre |
|---|---|---|---|
| {{ month }} | {% for year in stats.years %}{{ stats.monthly[year][mi]|money }} | {% if not loop.first %}{{ delta(compare(stats.monthly[year][mi], stats.monthly[year-1][mi])) }} | {% endif %}{% endfor %}{{ stats.monthly.values()|map(attribute=mi)|sum|money }} |
| Gesamt | {% for year in stats.years %}{{ stats.totals[year]|money }} | {% if not loop.first %}{{ delta(compare(stats.totals[year], stats.totals[year-1])) }} | {% endif %}{% endfor %}{{ stats.all_time|money }} |
Die Jahre im direkten Vergleich
Positionen · alle Jahre
Alle Jahre
Diagramme werden geladen. Alle Beträge sind auch als Tabellen verfügbar.
+