feat: Import-Rollback fuer bestaetigte Auszuege (v0.3.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
0.2.0
|
||||
0.3.0
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
</form>
|
||||
{% elif s.status == "confirmed" %}
|
||||
<span class="muted">Bestätigt — Buchungen übernommen</span>
|
||||
<form class="inline-form" hx-post="/api/imports/{{ s.id }}/rollback" hx-swap="none"
|
||||
hx-confirm="Diesen Import und ALLE zugehörigen Buchungen unwiderruflich löschen? Salden und Projektionen ändern sich."
|
||||
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
|
||||
<button type="submit">Import zurückrollen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form class="inline-form" hx-delete="/api/imports/{{ s.id }}" hx-swap="none"
|
||||
hx-confirm="Import „{{ s.filename }}“ wirklich verwerfen?"
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
<code>~/.local/share/finance_pod/data/inbox/</code> 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.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kategorien & Regeln:</strong> Buchungen lassen sich kategorisieren;
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user