diff --git a/finance/VERSION b/finance/VERSION index a82804c..4214542 100644 --- a/finance/VERSION +++ b/finance/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/finance/app/routers/imports.py b/finance/app/routers/imports.py index b024133..d8f9818 100644 --- a/finance/app/routers/imports.py +++ b/finance/app/routers/imports.py @@ -150,6 +150,25 @@ def confirm(statement_id: int, session: Session = Depends(get_session)): return StatementOut.model_validate(stmt) +@router.post("/{statement_id}/rollback") +def rollback(statement_id: int, session: Session = Depends(get_session)): + stmt = session.get(Statement, statement_id) + if stmt is None: + raise HTTPException(404, "Import nicht gefunden") + if stmt.status != "confirmed": + raise HTTPException(409, "Nur bestätigte Importe können zurückgerollt werden") + txs = session.execute( + select(Transaction).where(Transaction.statement_id == statement_id) + ).scalars().all() + deleted = len(txs) + for tx in txs: + session.delete(tx) + session.flush() + session.delete(stmt) + session.commit() + return {"deleted_transactions": deleted, "statement_id": statement_id} + + @router.delete("/{statement_id}", status_code=204) def delete_import(statement_id: int, session: Session = Depends(get_session)): stmt = session.get(Statement, statement_id) diff --git a/finance/app/templates/_import_list.html b/finance/app/templates/_import_list.html index 110301b..1b1c525 100644 --- a/finance/app/templates/_import_list.html +++ b/finance/app/templates/_import_list.html @@ -41,6 +41,11 @@ {% elif s.status == "confirmed" %} Bestätigt — Buchungen übernommen +
+ +
{% else %}
~/.local/share/finance_pod/data/inbox/ legen und "Inbox scannen"). Jeder Import wird geprüft (Anfangssaldo + Buchungen = Endsaldo) und landet als Entwurf: erst nach Kontrolle der Vorschau "Übernehmen" - klicken. Duplikate werden erkannt und nicht doppelt übernommen. + klicken. Duplikate werden erkannt und nicht doppelt übernommen. Bestätigte + Importe lassen sich als Ganzes zurückrollen (Auszug + alle seine + Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu zu + importieren; einzelne Buchungen bleiben unlöschbar.
  • Kategorien & Regeln: Buchungen lassen sich kategorisieren; diff --git a/finance/tests/test_gui.py b/finance/tests/test_gui.py index c9cebf6..577946f 100644 --- a/finance/tests/test_gui.py +++ b/finance/tests/test_gui.py @@ -39,10 +39,12 @@ def test_import_list_fragment_renders_after_login(client): def test_version_visible(client): + from pathlib import Path + version = (Path(__file__).resolve().parent.parent / "VERSION").read_text().strip() r = client.get("/api/version", headers={"Authorization": "Bearer test-key"}) - assert r.status_code == 200 and r.json()["version"] == "0.2.0" + assert r.status_code == 200 and r.json()["version"] == version client.post("/login", data={"username": "admin", "password": "geheim"}) - assert "v0.2.0" in client.get("/").text + assert f"v{version}" in client.get("/").text def test_buchungen_pagination(client, db): diff --git a/finance/tests/test_import_api.py b/finance/tests/test_import_api.py index de86ce7..13c81b7 100644 --- a/finance/tests/test_import_api.py +++ b/finance/tests/test_import_api.py @@ -142,6 +142,67 @@ def test_delete_error_import_returns_204(client, db): assert client.delete(f"/api/imports/{sid}", headers=H).status_code == 204 +def test_rollback_confirmed_import_deletes_transactions_and_statement(client, fake_parse, tmp_path, monkeypatch): + # Ein bestaetigter Import laesst sich als Ganzes zurueckrollen: alle seine + # Buchungen UND der Auszug selbst verschwinden, das Konto bleibt bestehen + # (Name/IBAN fuer einen Re-Import mit verbessertem Parser). + monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox")) + monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads")) + from app.config import get_settings + get_settings.cache_clear() + sid = _upload(client).json()["id"] + assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200 + account_id = client.get("/api/accounts", headers=H).json()[0]["id"] + + r = client.post(f"/api/imports/{sid}/rollback", headers=H) + assert r.status_code == 200 + body = r.json() + assert body == {"deleted_transactions": 1, "statement_id": sid} + + txs = client.get("/api/transactions", headers=H).json() + assert len(txs) == 0 + imports = client.get("/api/imports", headers=H).json() + assert all(s["id"] != sid for s in imports) + accounts = client.get("/api/accounts", headers=H).json() + assert any(a["id"] == account_id for a in accounts) + + +def test_rollback_then_reimport_restores_transactions(client, fake_parse, tmp_path, monkeypatch): + # Der eigentliche Zweck des Rollbacks: denselben Auszug erneut importieren + # koennen (z.B. nach einem verbesserten Parser), ohne dass alte Buchungen + # als Duplikate im Weg stehen. + monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox")) + monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads")) + from app.config import get_settings + get_settings.cache_clear() + sid = _upload(client).json()["id"] + client.post(f"/api/imports/{sid}/confirm", headers=H) + client.post(f"/api/imports/{sid}/rollback", headers=H) + + sid2 = _upload(client, "auszug_reimport.pdf").json()["id"] + prev = client.get(f"/api/imports/{sid2}/preview", headers=H).json() + assert prev["balance_ok"] is True + assert prev["duplicates"] == 0 + assert client.post(f"/api/imports/{sid2}/confirm", headers=H).status_code == 200 + txs = client.get("/api/transactions", headers=H).json() + assert len(txs) == 1 and txs[0]["status"] == "confirmed" + + +def test_rollback_draft_import_returns_409(client, fake_parse, tmp_path, monkeypatch): + monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox")) + monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads")) + from app.config import get_settings + get_settings.cache_clear() + sid = _upload(client).json()["id"] + r = client.post(f"/api/imports/{sid}/rollback", headers=H) + assert r.status_code == 409 + + +def test_rollback_unknown_import_returns_404(client): + r = client.post("/api/imports/999999/rollback", headers=H) + assert r.status_code == 404 + + def test_upload_corrupt_pdf_produces_error_statement_not_500(client, tmp_path, monkeypatch): # Datei besteht die Endungspruefung (.pdf), ist aber strukturell kein # gueltiges PDF -> pdfplumber wirft eine eigene Exception (keine