diff --git a/app/lifecycle.py b/app/lifecycle.py
index f4cb27d..d578b8b 100644
--- a/app/lifecycle.py
+++ b/app/lifecycle.py
@@ -1,6 +1,7 @@
"""Recoverable deletion and append-only voiding for the trusted workspace."""
import json
+import hashlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Annotated, Literal
@@ -27,6 +28,12 @@ def valid_text(cls, value):
return value
+class PurgeDeletedRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
+ confirmation: Literal["DELETE ALL"]
+
+
LIFECYCLE_SCHEMA = (
"ALTER TABLE receipts ADD COLUMN lifecycle_state TEXT NOT NULL DEFAULT 'ACTIVE' CHECK(lifecycle_state IN ('ACTIVE','DELETED','VOIDED'))",
"ALTER TABLE receipts ADD COLUMN lifecycle_version INTEGER NOT NULL DEFAULT 0",
@@ -94,22 +101,55 @@ def apply_lifecycle(store, receipt_id: str, request: LifecycleRequest) -> dict:
return event
+_PURGE_SAFE = "NOT EXISTS (SELECT 1 FROM receipt_amendments a WHERE a.receipt_id=r.receipt_id) AND NOT EXISTS (SELECT 1 FROM receipt_reviews v WHERE v.receipt_id=r.receipt_id AND json_extract(v.result_json,'$.decision')='APPROVED') AND NOT EXISTS (SELECT 1 FROM classifications c WHERE c.receipt_id=r.receipt_id AND c.decision='AUTO_FILED')"
+
+
+def _fingerprint(rows) -> str:
+ return hashlib.sha256(json.dumps([(row['receipt_id'], row['lifecycle_version']) for row in rows], separators=(',', ':')).encode()).hexdigest()
+
+
+def deleted_purge_preview(store) -> dict:
+ with store.connect() as db:
+ rows = db.execute("SELECT receipt_id, lifecycle_version FROM receipts WHERE lifecycle_state='DELETED' ORDER BY receipt_id").fetchall()
+ return {'count': len(rows), 'fingerprint': _fingerprint(rows)}
+
+
+def _erase_rows(db, rows, upload_dir: Path) -> int:
+ removed = 0
+ for row in rows:
+ receipt_id = str(UUID(row['receipt_id']))
+ # Use only generated filenames; never trust an image_path from the DB.
+ try:
+ for extension in ('.jpg', '.png', '.pdf', '.preview.png'):
+ (upload_dir / (receipt_id + extension)).unlink(missing_ok=True)
+ except OSError:
+ continue
+ for table in ('receipt_payment_events', 'receipt_reprocessing', 'review_audit', 'receipt_reviews', 'line_items', 'classifications', 'lifecycle_events'):
+ db.execute(f"DELETE FROM {table} WHERE receipt_id=?", (receipt_id,))
+ db.execute("DELETE FROM receipts WHERE receipt_id=?", (receipt_id,))
+ removed += 1
+ return removed
+
+
+def purge_deleted_now(store, upload_dir: Path, request: PurgeDeletedRequest) -> dict:
+ """Erase the confirmed snapshot of deleted receipts, including unexpired ones."""
+ with store.connect() as db:
+ db.execute('BEGIN IMMEDIATE')
+ rows = db.execute("SELECT receipt_id, lifecycle_version FROM receipts WHERE lifecycle_state='DELETED' ORDER BY receipt_id").fetchall()
+ if not rows or request.fingerprint != _fingerprint(rows):
+ raise ReviewConflict('Deleted receipts changed. Reload and confirm again')
+ safe = db.execute(f"SELECT count(*) FROM receipts r WHERE lifecycle_state='DELETED' AND {_PURGE_SAFE}").fetchone()[0]
+ if safe != len(rows):
+ raise ReviewConflict('Some deleted receipts contain protected decisions and cannot be erased')
+ # Existing audit triggers permit deletion only after purge_after has expired.
+ db.execute("UPDATE receipts SET purge_after=datetime('now','-1 second') WHERE lifecycle_state='DELETED'")
+ removed = _erase_rows(db, rows, upload_dir)
+ return {'removed': removed, 'remaining': len(rows) - removed}
+
+
def purge_expired(store, upload_dir: Path) -> int:
"""Bounded, retryable cleanup. Never follow a DB-supplied path or delete a void."""
- removed = 0
with store.connect() as db:
db.execute("BEGIN IMMEDIATE")
- rows = db.execute("SELECT receipt_id FROM receipts r WHERE lifecycle_state='DELETED' AND julianday(purge_after)<=julianday('now') AND NOT EXISTS (SELECT 1 FROM receipt_amendments a WHERE a.receipt_id=r.receipt_id) AND NOT EXISTS (SELECT 1 FROM receipt_reviews v WHERE v.receipt_id=r.receipt_id AND json_extract(v.result_json,'$.decision')='APPROVED') AND NOT EXISTS (SELECT 1 FROM classifications c WHERE c.receipt_id=r.receipt_id AND c.decision='AUTO_FILED') LIMIT 100").fetchall()
- for row in rows:
- receipt_id = str(UUID(row[0]))
- # Keep the DB row on a file failure so the next run can retry safely.
- try:
- for extension in ('.jpg', '.png', '.pdf', '.preview.png'):
- (upload_dir / (receipt_id + extension)).unlink(missing_ok=True)
- except OSError:
- continue
- for table in ('receipt_payment_events', 'receipt_reprocessing', 'review_audit', 'receipt_reviews', 'line_items', 'classifications', 'lifecycle_events'):
- db.execute(f"DELETE FROM {table} WHERE receipt_id=?", (receipt_id,))
- db.execute("DELETE FROM receipts WHERE receipt_id=?", (receipt_id,))
- removed += 1
- return removed
+ rows = db.execute(f"SELECT receipt_id FROM receipts r WHERE lifecycle_state='DELETED' AND julianday(purge_after)<=julianday('now') AND {_PURGE_SAFE} LIMIT 100").fetchall()
+ return _erase_rows(db, rows, upload_dir)
diff --git a/app/main.py b/app/main.py
index 3d8e307..baf1e7f 100644
--- a/app/main.py
+++ b/app/main.py
@@ -30,7 +30,8 @@
)
from app.images import receipt_image, receipt_preview
from app.agents.router import build_agent_router
-from app.lifecycle import LifecycleRequest, apply_lifecycle, purge_expired
+from app.lifecycle import (LifecycleRequest, PurgeDeletedRequest, apply_lifecycle,
+ deleted_purge_preview, purge_deleted_now, purge_expired)
from app.reprocessing import ReprocessRequest, reprocess
from app.statements import (
MonthlyExportRequest,
@@ -642,6 +643,17 @@ async def lifecycle(receipt_id: UUID, body: LifecycleRequest,
settings: Annotated[Settings, Depends(require_api_key)]) -> dict:
return await run_in_threadpool(apply_lifecycle, ReceiptStore(settings.database_path), str(receipt_id), body)
+ @api.get("/receipts/deleted/purge-preview", tags=["receipts"],
+ summary="Preview the current deleted receipts before permanent erasure")
+ async def preview_deleted_purge(settings: Annotated[Settings, Depends(require_api_key)]) -> dict:
+ return await run_in_threadpool(deleted_purge_preview, ReceiptStore(settings.database_path))
+
+ @api.post("/receipts/deleted/purge", tags=["receipts"],
+ summary="Permanently erase the confirmed set of deleted receipts")
+ async def purge_deleted(body: PurgeDeletedRequest,
+ settings: Annotated[Settings, Depends(require_api_key)]) -> dict:
+ return await run_in_threadpool(purge_deleted_now, ReceiptStore(settings.database_path), settings.upload_dir, body)
+
@api.get("/reviews", tags=["reviews"], summary="1. List receipts awaiting human review",
description="Read only. Copy a receipt_id, then use GET /receipts/{receipt_id}. Empty items means no pending receipts on THIS server. Local port 8000 and the AWS tunnel port 18000 use separate databases. Finalized, AUTO_FILED, FAILED and PROCESSING receipts are excluded.")
async def reviews(settings: Annotated[Settings, Depends(require_api_key)],
diff --git a/docs/receipt-lifecycle.md b/docs/receipt-lifecycle.md
index 61bdf12..39e3148 100644
--- a/docs/receipt-lifecycle.md
+++ b/docs/receipt-lifecycle.md
@@ -28,6 +28,8 @@ The restore deadline is exactly 30 days after deletion, in UTC; the UI displays
Cleanup removes only server-generated UUID filenames within the configured upload directory, never stored arbitrary paths. It removes the receipt's dependent rows, including rejection/lifecycle evidence, only after expiry. Approved and amendment audit protections remain. Voided receipts have no purge deadline. A generic server warning indicates a failed cleanup pass without logging receipt contents.
+**Empty Deleted receipts** allows the authenticated bookkeeper to erase the entire Deleted receipts tab before the normal deadline. The dialog requires typing `DELETE ALL` and the API verifies a snapshot of deleted receipt IDs and versions before erasing them. A changed snapshot causes a conflict instead of deleting newly added records. The operation preserves the same audit and filename safeguards as automatic cleanup and reports any files that could not be erased for automatic retry. Restoring the erased receipts through the app is impossible.
+
This is application retention, not deletion from independent backups. Backups retain their own lifetime. Before deploying, back up the database and uploads together. Migration from schema versions 1–3 to 4 preserves existing evidence and marks every current record ACTIVE. No existing record is automatically deleted or voided on migration.
`POST /receipts/{id}/lifecycle` accepts `request_id`, `action`, `expected_version` (lifecycle_version), `expected_record_version`, `reviewer`, and `reason`. Reasons require 10–2000 characters. Use the same UUID and payload after an uncertain response. Actions run inside a write transaction; stale versions, processing receipts, invalid transitions, and restore collisions return 409. Inspect the current record before retrying a conflict.
diff --git a/docs/user-guide.md b/docs/user-guide.md
index 844ede1..3fc462f 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -176,6 +176,8 @@ Choose **Deleted receipts** in the top navigation to inspect recoverable records
**Restore receipt** is available before the 30-day deadline, subject to the app’s duplicate-file checks. The lifecycle history preserves the recorded actions and self-reported reviewer labels. In this image an earlier restore appears in the history, but the receipt currently shown is **Deleted**; the button has not been used in this pictured state. Processing receipts must finish processing before deletion; accepted receipts use **Void receipt** and do not enter Deleted receipts. See [receipt lifecycle](receipt-lifecycle.md) for eligibility, retention and conflict handling.
+To clear this tab immediately, select **Empty Deleted receipts**, check the count in the confirmation dialog, type `DELETE ALL`, and select **Erase deleted receipts**. This permanently removes all currently deleted receipts and their saved files, including receipts still within their restore window. Active and voided receipts remain. If the list changes while the dialog is open, reload and confirm the new count. Independent backups follow their own retention policy.
+
## Monthly close
### Find the period
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 56f5076..77636e5 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -18,6 +18,9 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
import {
Table,
TableBody,
@@ -301,7 +304,13 @@ function Workspace({
[filters, setFilters] = useState(emptyFilters),
[checked, setChecked] = useState