diff --git a/backend/app/routers/imports.py b/backend/app/routers/imports.py index 37dc7a0..ab5a4f5 100644 --- a/backend/app/routers/imports.py +++ b/backend/app/routers/imports.py @@ -2,12 +2,12 @@ import json -from fastapi import APIRouter, Depends, File, Form, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned -from ..models import Account, AmountMode, ImportBatch, ImportTemplate, User +from ..models import Account, AmountMode, ImportBatch, ImportStatus, ImportTemplate, User from ..schemas import ( ImportBatchOut, ImportCommitIn, @@ -160,3 +160,21 @@ def undo(batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_ batch = get_owned(db, ImportBatch, batch_id, user) deleted = csv_import.undo_batch(db, user, batch) return {"deleted": deleted} + + +@router.delete("/batches/{batch_id}/record", status_code=status.HTTP_204_NO_CONTENT) +def remove_batch_record( + batch_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + """Entfernt einen rückgängig gemachten Eintrag aus dem Verlauf. + + Nur für bereits zurückgenommene Importe: Solange ein Import steht, ist der + Eintrag die einzige Möglichkeit, seine Buchungen wieder loszuwerden.""" + batch = get_owned(db, ImportBatch, batch_id, user) + if batch.status != ImportStatus.undone: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Nur rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen.", + ) + db.delete(batch) + db.commit() diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 1021051..1f36b83 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -519,3 +519,23 @@ def test_reconciliation_flags_rows_waiting_for_target(admin): assert rec["difference_cents"] == 9200 assert rec["first_mismatch"]["expected_cents"] == 9200 assert rec["first_mismatch"]["actual_cents"] == 0 + + +def test_remove_undone_batch_from_history(admin): + """Rückgängig gemachte Importe lassen sich aus dem Verlauf entfernen – + laufende nicht, sonst verlöre man die Möglichkeit, sie zurückzunehmen.""" + giro = _account(admin, name="Verlauf", currency="EUR") + _account(admin, name="Spar Verlauf", type="sparkonto", currency="EUR", + identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}]) + rows = _preview(admin, giro["id"]).json()["rows"] + batch = admin.post("/api/imports/commit", + json={"account_id": giro["id"], "filename": "v.csv", "rows": rows}).json() + + # Solange der Import steht, bleibt der Eintrag bestehen. + r = admin.delete(f"/api/imports/batches/{batch['id']}/record") + assert r.status_code == 400 + assert any(b["id"] == batch["id"] for b in admin.get("/api/imports/batches").json()) + + admin.delete(f"/api/imports/batches/{batch['id']}") # rückgängig machen + assert admin.delete(f"/api/imports/batches/{batch['id']}/record").status_code == 204 + assert not any(b["id"] == batch["id"] for b in admin.get("/api/imports/batches").json()) diff --git a/web/src/api.js b/web/src/api.js index 525aeba..36d870e 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -242,6 +242,7 @@ export const api = { importCommit: (body) => request("/imports/commit", { method: "POST", body }), importBatches: () => request("/imports/batches"), undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }), + removeImportBatch: (id) => request(`/imports/batches/${id}/record`, { method: "DELETE" }), // PDF/Bild-Beleg documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }), diff --git a/web/src/finance.css b/web/src/finance.css index 932d8ca..28a0b44 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1077,3 +1077,13 @@ tr.row-dup { .restore-summary tr:last-child td { border-bottom: none; } + +/* Zweiter Buchen-Knopf unter der Prüfliste – bei vielen Zeilen ist der obere + längst aus dem Bild gescrollt. */ +.commit-footer { + display: flex; + justify-content: flex-end; + padding-top: var(--sp-3); + margin-top: var(--sp-2); + border-top: 1px solid var(--border); +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 96bdd0f..6779bd8 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -340,7 +340,9 @@ "filterValue": "mit dem Wert", "filterValuePlaceholder": "z. B. Giro", "filterNone": "— kein Filter —", - "filterHint": "Revolut legt alle Pockets (Giro, Erspartes, Anlagekonto …) in EINE Datei, jedes mit eigenem Kontostand. Filtere hier auf ein Produkt und lege für die anderen eigene Konten an – sonst zählen die Pocket-Bewegungen doppelt." + "filterHint": "Revolut legt alle Pockets (Giro, Erspartes, Anlagekonto …) in EINE Datei, jedes mit eigenem Kontostand. Filtere hier auf ein Produkt und lege für die anderen eigene Konten an – sonst zählen die Pocket-Bewegungen doppelt.", + "removeEntry": "Eintrag entfernen", + "removeEntryConfirm": "Den Verlaufs-Eintrag „{{file}}“ entfernen? Die Buchungen sind bereits zurückgenommen." }, "password": { "show": "Passwort anzeigen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f9efc3e..65179f2 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -340,7 +340,9 @@ "filterValue": "with value", "filterValuePlaceholder": "e.g. Current", "filterNone": "— no filter —", - "filterHint": "Revolut puts every pocket (Current, Savings, Investments …) into ONE file, each with its own balance. Filter to one product here and create separate accounts for the others – otherwise pocket movements are counted twice." + "filterHint": "Revolut puts every pocket (Current, Savings, Investments …) into ONE file, each with its own balance. Filter to one product here and create separate accounts for the others – otherwise pocket movements are counted twice.", + "removeEntry": "Remove entry", + "removeEntryConfirm": "Remove the history entry “{{file}}”? Its transactions have already been undone." }, "password": { "show": "Show password", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index fbd7c8f..c11e45e 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -196,6 +196,15 @@ export default function Import() { } catch (e) { toast(e.message, "error"); } } + async function entfernen(b) { + if (!(await confirm({ + title: t("import.removeEntry"), + message: t("import.removeEntryConfirm", { file: b.filename || "—" }), + danger: true, confirmLabel: t("common.delete"), + }))) return; + try { await api.removeImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); } + } + async function undo(b) { if (!(await confirm({ title: t("import.undo"), message: t("import.undoConfirm", { file: b.filename }), danger: true, confirmLabel: t("import.undo") }))) return; try { await api.undoImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); } @@ -325,7 +334,15 @@ export default function Import() { - ) : {t("import.undone")}), + ) : ( + + {t("import.undone")} + + + )), }, // eslint-disable-next-line react-hooks/exhaustive-deps ], [t, formatDate, accountById]); @@ -614,6 +631,13 @@ export default function Import() { rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate || r.is_counterpart ? "row-dup" : "")} columns={reviewColumns} /> + {/* Zweiter Knopf unter der Liste: bei vielen Zeilen muss man zum Buchen + sonst wieder ganz nach oben scrollen. */} +
+ +
)}