From f375fc5eaa012fb3ebcdcca21d4e198ea215b60f Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 19 Aug 2026 20:35:02 +0200 Subject: [PATCH] feat: Backup/Export + Import-UX klarer + Raiffeisen-Preset-Fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backup & Export: - routers/backup: /backup/db (Admin, konsistentes SQLite-Online-Backup), /export/transactions.csv|json und /export/all.json (owner-gescoped). - Einstellungen: Bereich „Backup & Export" mit Download-Buttons. docs/BACKUP.md (Wiederherstellung + LXC-Sicherung). pytest für Export + Admin-Schutz. Import-Verbesserungen (Nutzer-Feedback zum Ablauf): - Klarer Hinweis, dass die Zuordnungstabelle nur Beispielzeilen zeigt und „Vorschau erstellen" die vollständige Prüfliste + Buchen-Button erzeugt. - Zusammenfassung (erkannt/gebucht) und Warnung, wenn keine gültige Zeile erkannt wurde (Hinweis auf Format/Bank-Vorlage). - Fix: Raiffeisen-Preset hat has_header=true (der Export hat eine Kopfzeile). Co-Authored-By: Claude Opus 4.8 --- backend/app/main.py | 2 + backend/app/routers/backup.py | 200 +++++++++++++++++++++++++++++ backend/app/services/csv_import.py | 2 +- backend/tests/test_backup.py | 59 +++++++++ docs/BACKUP.md | 45 +++++++ docs/ROADMAP.md | 5 +- web/src/api.js | 6 + web/src/locales/de.json | 13 +- web/src/locales/en.json | 13 +- web/src/pages/Import.jsx | 9 ++ web/src/pages/Settings.jsx | 36 ++++++ 11 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 backend/app/routers/backup.py create mode 100644 backend/tests/test_backup.py create mode 100644 docs/BACKUP.md diff --git a/backend/app/main.py b/backend/app/main.py index 2555ecf..ea76aad 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,7 @@ from .migrate import upgrade_to_head from .routers import ( accounts, auth, + backup, budgets, categories, dashboard, @@ -107,6 +108,7 @@ api.include_router(budgets.router) api.include_router(dashboard.router) api.include_router(imports.router) api.include_router(documents.router) +api.include_router(backup.router) api.include_router(rates.router) app.include_router(api) diff --git a/backend/app/routers/backup.py b/backend/app/routers/backup.py new file mode 100644 index 0000000..a5f3cda --- /dev/null +++ b/backend/app/routers/backup.py @@ -0,0 +1,200 @@ +"""Backup & Export: kompletter DB-Download (Admin) und Datenexport je Nutzer.""" + +import csv +import io +import os +import sqlite3 +import tempfile +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from starlette.background import BackgroundTask + +from ..config import get_settings +from ..database import get_db +from ..deps import get_current_user, require_admin +from ..models import ( + Account, + AccountIdentifier, + BudgetTemplateLine, + Category, + CategoryGroup, + DashboardLayout, + PlannedIncomeLine, + Transaction, + User, +) + +router = APIRouter(tags=["backup"]) + + +def _ts() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + + +def _money(cents: int | None) -> str: + return f"{(cents or 0) / 100:.2f}" + + +# --- Komplettes DB-Backup (nur Admin) -------------------------------------- + + +@router.get("/backup/db") +def backup_db(_: User = Depends(require_admin)): + url = get_settings().effective_database_url + if not url.startswith("sqlite:///"): + raise HTTPException(status_code=400, detail="DB-Backup ist nur für SQLite verfügbar.") + db_path = url.replace("sqlite:///", "", 1) + tmp = tempfile.NamedTemporaryFile(prefix="cf-backup-", suffix=".db", delete=False) + tmp.close() + # Online-Backup-API: konsistente Kopie auch bei laufendem Betrieb. + src = sqlite3.connect(db_path) + dst = sqlite3.connect(tmp.name) + try: + with dst: + src.backup(dst) + finally: + dst.close() + src.close() + return FileResponse( + tmp.name, + media_type="application/octet-stream", + filename=f"certain-finance-{_ts()}.db", + background=BackgroundTask(os.remove, tmp.name), + ) + + +# --- Transaktionsexport ---------------------------------------------------- + + +def _maps(db: Session, user: User) -> tuple[dict[int, Account], dict[int, Category]]: + accs = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)} + cats = {c.id: c for c in db.query(Category).filter(Category.owner_id == user.id)} + return accs, cats + + +def _txns(db: Session, user: User) -> list[Transaction]: + return ( + db.query(Transaction) + .filter(Transaction.owner_id == user.id) + .order_by(Transaction.date, Transaction.id) + .all() + ) + + +@router.get("/export/transactions.csv") +def export_transactions_csv(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + accs, cats = _maps(db, user) + buf = io.StringIO() + w = csv.writer(buf, delimiter=";") + w.writerow(["Datum", "Typ", "Konto", "Betrag", "Waehrung", "EUR-Wert", + "Zielkonto", "Kategorie", "Empfaenger", "Verwendungszweck", "Notiz"]) + for t in _txns(db, user): + acc = accs.get(t.account_id) + w.writerow([ + t.date.isoformat(), t.type.value, acc.name if acc else "", + _money(t.amount_cents), acc.currency if acc else "", _money(t.amount_eur_cents), + accs[t.to_account_id].name if t.to_account_id and t.to_account_id in accs else "", + cats[t.category_id].name if t.category_id and t.category_id in cats else "", + t.payee or "", t.description or "", t.notes or "", + ]) + return Response( + content=buf.getvalue().encode("utf-8-sig"), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.csv"'}, + ) + + +def _txn_dict(t: Transaction, accs: dict[int, Account], cats: dict[int, Category]) -> dict: + acc = accs.get(t.account_id) + return { + "date": t.date.isoformat(), + "type": t.type.value, + "account": acc.name if acc else None, + "currency": acc.currency if acc else None, + "amount_cents": t.amount_cents, + "amount_eur_cents": t.amount_eur_cents, + "to_account": accs[t.to_account_id].name if t.to_account_id in accs else None, + "to_amount_cents": t.to_amount_cents, + "category": cats[t.category_id].name if t.category_id in cats else None, + "payee": t.payee, + "description": t.description, + "notes": t.notes, + } + + +@router.get("/export/transactions.json") +def export_transactions_json(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + accs, cats = _maps(db, user) + data = [_txn_dict(t, accs, cats) for t in _txns(db, user)] + return Response( + content=_json_bytes(data), + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.json"'}, + ) + + +# --- Gesamt-Export (alle eigenen Daten als JSON) --------------------------- + + +def _json_bytes(obj) -> bytes: + import json + + return json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") + + +@router.get("/export/all.json") +def export_all_json(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + accs, cats = _maps(db, user) + idents: dict[int, list] = {} + for i in db.query(AccountIdentifier).join(Account).filter(Account.owner_id == user.id): + idents.setdefault(i.account_id, []).append({"kind": i.kind.value, "value": i.value}) + + accounts = [{ + "name": a.name, "type": a.type.value, "currency": a.currency, + "opening_balance_cents": a.opening_balance_cents, + "opening_date": a.opening_date.isoformat() if a.opening_date else None, + "icon": a.icon, "color": a.color, "archived": a.is_archived, + "identifiers": idents.get(a.id, []), + } for a in accs.values()] + + groups = [] + for g in db.query(CategoryGroup).filter(CategoryGroup.owner_id == user.id).order_by(CategoryGroup.position): + groups.append({ + "name": g.name, "kind": g.kind.value, "icon": g.icon, "color": g.color, + "categories": [ + {"name": c.name, "icon": c.icon, "color": c.color} + for c in sorted(g.categories, key=lambda x: x.position) + ], + }) + + budget_template = [ + {"category": cats[l.category_id].name if l.category_id in cats else None, "planned_cents": l.planned_cents} + for l in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id) + ] + planned_income = [ + {"label": l.label, "planned_cents": l.planned_cents, + "category": cats[l.category_id].name if l.category_id in cats else None, + "target_account": accs[l.target_account_id].name if l.target_account_id in accs else None} + for l in db.query(PlannedIncomeLine).filter(PlannedIncomeLine.owner_id == user.id) + ] + dash = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first() + + data = { + "exported_at": datetime.now(timezone.utc).isoformat(), + "app": "Certain Finance", + "user": user.username, + "accounts": accounts, + "category_groups": groups, + "transactions": [_txn_dict(t, accs, cats) for t in _txns(db, user)], + "budget_template": budget_template, + "planned_income": planned_income, + "dashboard_layout": dash.layout if dash else None, + } + return Response( + content=_json_bytes(data), + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="certain-finance-export-{_ts()}.json"'}, + ) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 9269831..48fffb5 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -39,7 +39,7 @@ PRESETS: list[dict] = [ "decimal_sep": ".", "date_format": "%Y-%m-%d %H:%M:%S.%f", "encoding": "utf-8", - "has_header": False, + "has_header": True, "skip_rows": 0, "amount_mode": "signed", # Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta. diff --git a/backend/tests/test_backup.py b/backend/tests/test_backup.py new file mode 100644 index 0000000..61af66c --- /dev/null +++ b/backend/tests/test_backup.py @@ -0,0 +1,59 @@ +import json +import secrets +from datetime import date + + +def _setup(admin): + acc = admin.post("/api/accounts", json={"name": "Exp", "currency": "EUR"}).json() + admin.post("/api/transactions", json={ + "type": "expense", "date": str(date.today()), "account_id": acc["id"], + "amount_cents": 4200, "payee": "Testladen", "description": "Etwas", + }) + return acc + + +def test_export_transactions_csv(admin): + _setup(admin) + r = admin.get("/api/export/transactions.csv") + assert r.status_code == 200 + assert "text/csv" in r.headers["content-type"] + body = r.content.decode("utf-8-sig") + assert "Datum;Typ;Konto" in body.splitlines()[0] + assert "Testladen" in body + + +def test_export_transactions_json(admin): + _setup(admin) + r = admin.get("/api/export/transactions.json") + assert r.status_code == 200 + data = json.loads(r.content) + assert any(t["payee"] == "Testladen" for t in data) + + +def test_export_all_json(admin): + _setup(admin) + r = admin.get("/api/export/all.json") + assert r.status_code == 200 + data = json.loads(r.content) + assert data["app"] == "Certain Finance" + assert len(data["accounts"]) >= 1 + assert len(data["transactions"]) >= 1 + + +def test_db_backup_admin_only(admin): + from fastapi.testclient import TestClient + from app.main import app + + # Admin darf. + r = admin.get("/api/backup/db") + assert r.status_code == 200 + assert r.content[:16].startswith(b"SQLite format 3") + + # Normaler Nutzer darf nicht. + u, p = "u_" + secrets.token_hex(4), "pw" + admin.post("/api/users", json={"username": u, "password": p, "role": "user"}) + other = TestClient(app) + other.post("/api/auth/login", data={"username": u, "password": p}) + assert other.get("/api/backup/db").status_code == 403 + # ...aber seinen eigenen Export schon (leer). + assert other.get("/api/export/all.json").status_code == 200 diff --git a/docs/BACKUP.md b/docs/BACKUP.md new file mode 100644 index 0000000..68b4953 --- /dev/null +++ b/docs/BACKUP.md @@ -0,0 +1,45 @@ +# Backup & Wiederherstellung + +## In der App + +**Einstellungen → Backup & Export:** + +- **Buchungen (CSV/JSON)** und **Alle Daten (JSON)** — exportiert die *eigenen* + Daten des angemeldeten Nutzers (owner-gescoped) zum Archivieren/Weiterverarbeiten. +- **Komplettes DB-Backup** (nur Admin) — lädt eine konsistente Kopie der gesamten + SQLite-Datenbank herunter (alle Nutzer). Erzeugt über die SQLite-Online-Backup-API, + also auch bei laufendem Betrieb gefahrlos. + +## Wiederherstellen (DB-Backup) + +Die heruntergeladene `.db`-Datei ist die komplette Datenbank. Zum Wiederherstellen +im LXC: + +```bash +systemctl stop certain-finance +cp certain-finance-YYYYMMDD-HHMMSS.db /var/lib/certain-finance/app.db +# WAL-/SHM-Reste entfernen, damit die Kopie sauber übernommen wird: +rm -f /var/lib/certain-finance/app.db-wal /var/lib/certain-finance/app.db-shm +chown certain-finance:certain-finance /var/lib/certain-finance/app.db 2>/dev/null || true +systemctl start certain-finance +``` + +## Datei-Backup (LXC) + +Die Datenbank liegt unter `/var/lib/certain-finance/app.db`. + +- **Proxmox-Snapshot:** `vzdump` des Containers sichert alles (empfohlen). +- **Cron-Kopie:** täglich eine konsistente Kopie ablegen und rotieren: + +```bash +# /etc/cron.daily/certain-finance-backup (ausführbar machen) +#!/bin/sh +D=/var/lib/certain-finance +mkdir -p "$D/backups" +sqlite3 "$D/app.db" ".backup '$D/backups/app-$(date +\%Y\%m\%d).db'" +# älter als 30 Tage löschen +find "$D/backups" -name 'app-*.db' -mtime +30 -delete +``` + +`update.sh` legt außerdem vor jeder Migration automatisch eine Kopie unter +`/var/lib/certain-finance/backups/pre-update-*.db` an. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5ff33f8..2f45111 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -32,5 +32,6 @@ via IBAN, Batch-Undo. Vorgefertigte Bank-Templates folgen aus Nutzer-Beispielen. Lokal: PyMuPDF (PDF-Text) + Tesseract-OCR (Scans/Bilder), heuristischer Zeilen-Parser (Datum + Betrag), speist dieselbe Prüfansicht. Best-Effort. -## Querschnitt – Backup/Export -DB-Backup-Download + Export je Entität (CSV/JSON); dokumentierte LXC-Sicherung. +## Querschnitt – Backup/Export ✅ +DB-Backup-Download (Admin) + Export je Entität (CSV/JSON); dokumentierte +LXC-Sicherung (docs/BACKUP.md). diff --git a/web/src/api.js b/web/src/api.js index 7bd175b..0c2de4a 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -201,4 +201,10 @@ export const api = { // PDF/Bild-Beleg documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }), + + // Backup & Export + downloadDbBackup: () => downloadFile("/backup/db", `certain-finance-${zeitstempel()}.db`), + exportTransactionsCsv: () => downloadFile("/export/transactions.csv", `transaktionen_${zeitstempel()}.csv`), + exportTransactionsJson: () => downloadFile("/export/transactions.json", `transaktionen_${zeitstempel()}.json`), + exportAllJson: () => downloadFile("/export/all.json", `certain-finance-export_${zeitstempel()}.json`), }; diff --git a/web/src/locales/de.json b/web/src/locales/de.json index c3d68ec..7a08cf8 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -72,7 +72,14 @@ "dateFormat": "Datumsformat", "saved": "Gespeichert.", "languageDe": "Deutsch", - "languageEn": "Englisch" + "languageEn": "Englisch", + "backup": "Backup & Export", + "exportHint": "Exportiere deine eigenen Daten zum Archivieren oder Weiterverarbeiten.", + "exportTxnCsv": "Buchungen (CSV)", + "exportTxnJson": "Buchungen (JSON)", + "exportAll": "Alle Daten (JSON)", + "dbBackup": "Komplettes DB-Backup herunterladen", + "dbBackupHint": "Vollständige Datenbank aller Nutzer (nur Admin) – zum Wiederherstellen sichern." }, "dashboard": { "title": "Übersicht", @@ -234,6 +241,10 @@ "colPurpose": "Verwendungszweck", "colIban": "Gegen-IBAN", "makePreviewHint": "Ordne mindestens Datum und Betrag zu.", + "rawHint": "Nur die ersten Zeilen – sie dienen nur der Spaltenzuordnung.", + "makePreviewNote": "„Vorschau erstellen“ zeigt danach ALLE Buchungen zum Prüfen und Buchen.", + "summary": "{{total}} Zeilen erkannt · {{n}} werden gebucht (Duplikate/ungültige ausgenommen).", + "noneValid": "Keine gültige Buchung erkannt. Prüfe Datumsformat, Dezimaltrenner und Spaltenzuordnung – am einfachsten oben die passende Bank-Vorlage wählen.", "commit": "{{n}} Buchungen übernehmen", "imported": "{{n}} Buchungen importiert.", "importedCount": "{{n}} gebucht", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index cee946f..6f5505b 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -72,7 +72,14 @@ "dateFormat": "Date format", "saved": "Saved.", "languageDe": "German", - "languageEn": "English" + "languageEn": "English", + "backup": "Backup & export", + "exportHint": "Export your own data for archiving or further processing.", + "exportTxnCsv": "Transactions (CSV)", + "exportTxnJson": "Transactions (JSON)", + "exportAll": "All data (JSON)", + "dbBackup": "Download full DB backup", + "dbBackupHint": "Complete database of all users (admin only) — keep it for restore." }, "dashboard": { "title": "Overview", @@ -234,6 +241,10 @@ "colPurpose": "Purpose", "colIban": "Counterparty IBAN", "makePreviewHint": "Map at least date and amount.", + "rawHint": "Only the first rows – just for mapping the columns.", + "makePreviewNote": "“Build preview” then shows ALL transactions to review and import.", + "summary": "{{total}} rows detected · {{n}} will be imported (duplicates/invalid excluded).", + "noneValid": "No valid transaction detected. Check date format, decimal separator and column mapping – easiest is to pick the matching bank preset above.", "commit": "Import {{n}} transactions", "imported": "Imported {{n}} transactions.", "importedCount": "{{n}} imported", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index e480a8c..a991633 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -315,6 +315,7 @@ export default function Import() { {raw && (

{t("import.step3")}

+

{t("import.rawHint")}

@@ -357,6 +358,7 @@ export default function Import() { +

{t("import.makePreviewNote")}

)} @@ -369,6 +371,13 @@ export default function Import() { {t("import.commit", { n: includeCount })} +

{t("import.summary", { total: rows.length, n: includeCount })}

+ {rows.length > 0 && includeCount === 0 && ( +
+ + {t("import.noneValid")} +
+ )}
diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index 2114e25..d99555b 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -1,10 +1,14 @@ import { useTranslation } from "react-i18next"; import { useSettings } from "../settings"; +import { useAuth } from "../auth"; import { useToast } from "../toast"; +import { api } from "../api"; +import Icon from "../components/Icon"; export default function Settings() { const { t } = useTranslation(); const s = useSettings(); + const { isAdmin } = useAuth(); const toast = useToast(); async function set(key, value) { @@ -16,6 +20,14 @@ export default function Settings() { } } + async function dl(fn) { + try { + await fn(); + } catch (err) { + toast(err.message, "error"); + } + } + return (

{t("settings.title")}

@@ -62,6 +74,30 @@ export default function Settings() {

{t("settings.budgetStartDayHint")}

+ +
+

{t("settings.backup")}

+

{t("settings.exportHint")}

+
+ + + +
+ {isAdmin && ( + <> +

{t("settings.dbBackupHint")}

+ + + )} +
); }