Inventar abgeschlossen: Es gibt bereits CSV-Profile, Import-Sessions und `import_session_id`, aber der User-Workflow hängt noch an `BudgetMonthlyImportPage`/Drive-Demo, und Review-Summary/Monthly-Dashboard zählen unterschiedlich. Ich schreibe jetzt RED-Tests für Upload-Service, Count-Policy und UI.

Explanation:
**Considering file upload implementation**

I’m thinking about simplifying the process by using JSON upload endpoints rather than multipart ones. The user mentioned a file upload dashboard, so I’m considering how the browser can use FileReader to convert text to JSON for temporary backend storage. I'll need to set up POST endpoints for upload and preview, and I'll create some RED tests for that. Implementing budget upload features will involve ensuring idempotency and preventing duplicate sessions, utilizing source hash tables and labels to keep track of files.
**Planning file handling strategy**

I'm considering that user experience with file names can vary, which means a "same file again" might actually refer to different submissions. I think using a source hash for file IDs and examining raw fingerprints could help. I'll also want to store the original file name in the session while generating candidates. During dry runs, I’ll parse and aggregate but not compute amounts yet. I may need to address duplicate counting by precomputing fingerprints and adding constant statuses for better summaries. I’m also thinking about creating a new BudgetImportPage.vue to streamline the user interface.
**Structuring testing strategy**

I’m thinking about creating a new page that could serve as an admin fallback route but won't be included in the navigation. This page will utilize FileReader and API functions, so it's essential to implement both backend and frontend tests. I'll start by writing those tests to ensure everything works properly, allowing us to catch any failures early. For efficiency, I might also use execute_code to patch multiple files simultaneously, making sure I cover all bases before going live.

from __future__ import annotations

from jarvis_finance.services.budget_accounts import confirm_create_budget_account
from jarvis_finance.services.budget_categories import confirm_create_category
from jarvis_finance.services.budget_import_upload import (
    confirm_uploaded_budget_import_candidates,
    get_budget_import_session,
    list_budget_import_sessions,
    preview_uploaded_budget_import,
)
from jarvis_finance.services.budget_imports import list_transaction_candidates, review_candidate_summary
from jarvis_finance.services.budget_monthly_import import get_monthly_import_dashboard
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations

def db():
    conn = connect_memory()
    apply_migrations(conn)
    return conn

def setup_base(conn):
    account = confirm_create_budget_account(conn, {"name": "Budget Konto", "account_type": "cash", "currency": "CHF"})["entity_id"]
    expense = confirm_create_category(conn, {"name": "Essen & Haushalt", "category_type": "expense"})["entity_id"]
    income = confirm_create_category(conn, {"name": "Lohn Marcel", "category_type": "income"})["entity_id"]
    return account, expense, income

VISA_CSV = "TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nT1,C1,2026-05-01,-10.00,CHF,Netflix,Abo\n"
MIGROS_CSV = "Datum;Zeit;Filiale;Kassennummer;Transaktionsnummer;Artikel;Menge;Aktion;Umsatz\n02.05.2026;10:00;Migros Test;1;99;Artikel;1;;1.00\n"
RAIFFEISEN_CSV = "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\nCH1;2026-05-03;Lohn Marcel;40.00;0;2026-05-03\n"
AKB_CSV = "Buchung;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF\n03.05.2026;03.05.2026;Lohn Marcel;;20.00;0\n"

def test_upload_preview_detects_supported_profiles_without_writing_candidates() -> None:
    conn = db(); _account, expense, income = setup_base(conn)
    cases = [
        ("visa.csv", VISA_CSV, "visa_credit_card"),
        ("migros.csv", MIGROS_CSV, "migros_receipts"),
        ("raiffeisen.csv", RAIFFEISEN_CSV, "raiffeisen_bank"),
        ("akb.csv", AKB_CSV, "akb_bank"),
    ]

    for filename, csv_text, profile in cases:
        result = preview_uploaded_budget_import(conn, filename=filename, csv_text=csv_text, profile="auto", default_category_id=expense, income_category_id=income)
        assert result["status"] == "dry_run"
        assert result["profile"] == profile
        assert result["file"]["detected"] is True
        assert result["totals"]["rows_total"] == 1
        assert "amount" not in str(result).lower()

    assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates").fetchone()[0] == 0

def test_upload_unknown_csv_returns_understandable_error() -> None:
    conn = db(); setup_base(conn)

    result = preview_uploaded_budget_import(conn, filename="unknown.csv", csv_text="foo;bar\n1;2\n", profile="auto")

    assert result["status"] == "failed"
    assert result["errors"]
    assert "Profil" in result["errors"][0] or "profile" in result["errors"][0].lower()
    assert result["totals"]["error_rows"] >= 1

def test_upload_confirm_creates_candidates_idempotently_and_links_review_filter() -> None:
    conn = db(); _account, expense, income = setup_base(conn)
    preview = preview_uploaded_budget_import(conn, filename="visa.csv", csv_text=VISA_CSV, profile="auto", default_category_id=expense, income_category_id=income)

    first = confirm_uploaded_budget_import_candidates(conn, preview["preview_id"])
    second = confirm_uploaded_budget_import_candidates(conn, preview["preview_id"])
    rows = list_transaction_candidates(conn, import_session_id=first["import_session_id"], status="open")
    session = get_budget_import_session(conn, first["import_session_id"])

    assert first["status"] == "candidates_created"
    assert first["totals"]["new_candidates"] == 1
    assert second["totals"]["new_candidates"] == 0
    assert second["totals"]["already_processed"] >= 1
    assert len(rows) == 1
    assert f"import_session_id={first['import_session_id']}" in first["review_url"]
    assert "status=open" in first["review_url"]
    assert session["source"] == "VISA"
    assert list_budget_import_sessions(conn)[0]["import_session_id"] == second["import_session_id"]

def test_user_candidate_counts_exclude_legacy_and_match_monthly_dashboard() -> None:
    conn = db(); setup_base(conn)
    rows = [
        ("pending", "unclear", 1, "2026-05-01"),
        ("auto_categorized", "subscription_auto", 0, "2026-05-02"),
        ("needs_review", "admin_other", 1, "2026-05-03"),
        ("duplicate_candidate", "duplicate", 1, "2026-05-04"),
        ("transfer_candidate", "transfer_candidate", 1, "2026-05-05"),
        ("confirmed", "unclear", 0, "2026-05-06"),
        ("ignored", "unclear", 1, "2026-05-07"),
        ("superseded", "unclear", 1, "2026-05-08"),
        ("covered_by_source", "covered_by_source", 0, "2026-05-09"),
        ("already_processed", "already_processed", 0, "2026-05-10"),
        ("pending", "legacy", 1, "2025-05-01"),
    ]
    for idx, (status, classification, requires_review, date) in enumerate(rows, start=1):
        conn.execute(
            """
            INSERT INTO budget_transaction_candidates(transaction_candidate_id, source_file_label, source_row_or_range, source_type, transaction_date, description, amount_original, currency_original, confidence, requires_review, status, classification, created_at, updated_at)
            VALUES (?, 'test.csv', ?, 'visa_credit_card', ?, ?, '1.00', 'CHF', '0.5', ?, ?, ?, '2026-05-19T00:00:00', '2026-05-19T00:00:00')
            """,
            (f"cand_{idx}", f"R{idx}", date, f"row {idx}", requires_review, status, classification),
        )
    conn.commit()

    review = review_candidate_summary(conn, budget_year="2026")
    monthly = get_monthly_import_dashboard(conn, budget_year="2026")

    assert review["open_total"] == 4
    assert review["possible_duplicates"] == 1
    assert review["covered_by_source"] == 1
    assert review["already_processed"] == 1
    assert monthly["user_counts"]["open_total"] == review["open_total"]
    assert monthly["user_counts"]["review_needed"] == review["review_needed"]
    assert monthly["user_counts"]["possible_duplicates"] == review["possible_duplicates"]


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_import_upload_ux_v1.py">
ERROR [5:6] Import "jarvis_finance.services.budget_import_upload" could not be resolved [reportMissingImports] (Pyright)
ERROR [73:46] No parameter named "import_session_id" [reportCallIssue] (Pyright)
ERROR [113:50] No parameter named "budget_year" [reportCallIssue] (Pyright)
</diagnostics>


terminal

source ~/jarvis_runtime/finance-system/venv/bin/activate && PYTHONPATH=src pytest tests/unit/test_budget_import_upload_ux_v1.py -q


Output:
==================================== ERRORS ====================================
________ ERROR collecting tests/unit/test_budget_import_upload_ux_v1.py ________
ImportError while importing test module '/home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_import_upload_ux_v1.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../../.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/unit/test_budget_import_upload_ux_v1.py:5: in <module>
    from jarvis_finance.services.budget_import_upload import (
E   ModuleNotFoundError: No module named 'jarvis_finance.services.budget_import_upload'
=========================== short test summary info ============================
ERROR tests/unit/test_budget_import_upload_ux_v1.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.30s


RED bestätigt: Upload-Service/Import-Session-Filter existiert noch nicht. Ich implementiere jetzt minimal-grün und ziehe die Count-Policy zentral.

Explanation:
**Checking implementation status**

I need to implement and route this properly. I should check the migration schema status to see if it includes the already_processed aspect. That makes me wonder if the table itself will have a CHECK to ensure its status. I think I'll read more about the schema details to make sure everything is aligned properly. It's important to have clarity before proceeding!


   917|            CREATE TABLE budget_transaction_candidates (
   918|                transaction_candidate_id TEXT PRIMARY KEY,
   919|                source_file_label TEXT NOT NULL,
   920|                source_row_or_range TEXT,
   921|                source_type TEXT NOT NULL DEFAULT 'csv_seed',
   922|                transaction_date TEXT,
   923|                description TEXT NOT NULL,
   924|                merchant TEXT,
   925|                amount_original TEXT,
   926|                currency_original TEXT NOT NULL DEFAULT 'CHF',
   927|                proposed_category_id TEXT REFERENCES budget_categories(category_id),
   928|                proposed_category_name TEXT,
   929|                duplicate_of_transaction_id TEXT REFERENCES budget_transactions(budget_transaction_id),
   930|                confidence TEXT NOT NULL DEFAULT '0',
   931|                requires_review INTEGER NOT NULL DEFAULT 1,
   932|                status TEXT NOT NULL DEFAULT 'pending',
   933|                notes TEXT,
   934|                created_at TEXT NOT NULL,
   935|                updated_at TEXT,
   936|                classification TEXT,
   937|                review_reason TEXT,
   938|                rule_id TEXT,
   939|                rule_name TEXT,
   940|                source_priority INTEGER NOT NULL DEFAULT 50,
   941|                covered_by_source TEXT,
   942|                receipt_key TEXT,
   943|                linked_candidate_id TEXT,
   944|                account_source TEXT,
   945|                raw_fingerprint TEXT,
   946|                confirmed_transaction_id TEXT REFERENCES budget_transactions(budget_transaction_id),
   947|                confirmed_at TEXT,
   948|                confirmed_by TEXT
   949|            );
   950|            INSERT INTO budget_transaction_candidates(
   951|                transaction_candidate_id, source_file_label, source_row_or_range, source_type,
   952|                transaction_date, description, merchant, amount_original, currency_original,
   953|                proposed_category_id, proposed_category_name, duplicate_of_transaction_id,
   954|                confidence, requires_review, status, notes, created_at, updated_at,
   955|                classification, review_reason, rule_id, rule_name, source_priority, covered_by_source,
   956|                receipt_key, linked_candidate_id, account_source, raw_fingerprint,
   957|                confirmed_transaction_id, confirmed_at, confirmed_by
   958|            )
   959|            SELECT transaction_candidate_id, source_file_label, source_row_or_range, source_type,
   960|                transaction_date, description, merchant, amount_original, currency_original,
   961|                proposed_category_id, proposed_category_name, duplicate_of_transaction_id,
   962|                confidence, requires_review, status, notes, created_at, updated_at,
   963|                classification, review_reason, rule_id, rule_name, source_priority, covered_by_source,
   964|                receipt_key, linked_candidate_id, account_source, raw_fingerprint,
   965|                confirmed_transaction_id, confirmed_at, confirmed_by
   966|            FROM budget_transaction_candidates__phase18;
   967|            DROP TABLE budget_transaction_candidates__phase18;
   968|            """
   969|        )
   970|    conn.executescript(
   971|        """
   972|        CREATE TABLE IF NOT EXISTS budget_transaction_tags__phase18_fixed (
   973|            budget_transaction_id TEXT NOT NULL,
   974|            tag_id TEXT NOT NULL REFERENCES budget_tags(tag_id),
   975|            PRIMARY KEY (budget_transaction_id, tag_id)
   976|        );
   977|        INSERT OR IGNORE INTO budget_transaction_tags__phase18_fixed(budget_transaction_id, tag_id)
   978|        SELECT budget_transaction_id, tag_id FROM budget_transaction_tags;
   979|        DROP TABLE budget_transaction_tags;
   980|        ALTER TABLE budget_transaction_tags__phase18_fixed RENAME TO budget_transaction_tags;
   981|
   982|        CREATE TABLE IF NOT EXISTS budget_transfers__phase18_fixed (
   983|            transfer_id TEXT PRIMARY KEY,
   984|            from_transaction_id TEXT NOT NULL,
   985|            to_transaction_id TEXT NOT NULL,
   986|            from_account_id TEXT NOT NULL REFERENCES budget_accounts(budget_account_id),
   987|

Use offset=987 to continue reading (showing 917-986 of 1465 lines)


Explanation:
**Evaluating file upload safety**

I'm considering how to label file uploads to avoid logging any sensitive filenames. If a filename has secrets, I want to ensure those aren't logged in the database. A better approach might be to sanitize filenames for display. I could use a safe version like `uploaded.csv`, but still need a way to retain user access to the file. I think I should derive periods from the rows and create a mapping for profile aliases like VISA.
**Clarifying user statuses**

I'm defining statuses like USER_OPEN_NORMAL, which includes options like pending, auto_categorized, needs_review, transfer_candidate, and possible_income. The income_candidate status is rare and separate from duplicate candidates. I want to make sure that total counts exclude duplicates. The user specified that duplicate candidates only appear in a specific tab called "Duplikat-Tab," not in the normal open candidates. It seems "User-relevant" open candidates can include some duplicates, but only in that designated tab.

from __future__ import annotations

import hashlib
import json
import os
import re
from pathlib import Path
from sqlite3 import Connection
from typing import Any
from urllib.parse import urlencode

from jarvis_finance.services.budget_common import new_id, now, row_to_dict
from jarvis_finance.services.budget_csv_imports import IMPORT_PROFILES, import_budget_csv_text, parse_budget_csv_text
from jarvis_finance.services.budget_monthly_import import _source_from_profile

MAX_UPLOAD_BYTES = 5 * 1024 * 1024
PROFILE_ALIASES = {
    "auto": None,
    "": None,
    "visa": "visa_credit_card",
    "visa_credit_card": "visa_credit_card",
    "migros": "migros_receipts",
    "migros_receipts": "migros_receipts",
    "raiffeisen": "raiffeisen_bank",
    "raiffeisen_bank": "raiffeisen_bank",
    "akb": "akb_bank",
    "akb_bank": "akb_bank",
}

def _runtime_dir() -> Path:
    root = Path(os.environ.get("JARVIS_FINANCE_RUNTIME_DIR") or Path.home() / "jarvis_runtime" / "finance-system")
    path = root / "import_uploads"
    path.mkdir(parents=True, exist_ok=True)
    return path

def _profile_name(profile: str | None) -> str | None:
    key = str(profile or "auto").strip().lower()
    if key not in PROFILE_ALIASES:
        return key
    return PROFILE_ALIASES[key]

def _safe_public_file_name(filename: str, profile: str | None, digest: str) -> str:
    suffix = ".csv"
    raw_suffix = Path(str(filename or "")).suffix.lower()
    if raw_suffix == ".csv":
        suffix = ".csv"
    source = _source_from_profile(profile, filename)
    return re.sub(r"[^A-Za-z0-9_.-]+", "_", f"{source.lower()}_upload_{digest[:12]}{suffix}")[:120]

def _decode_csv_text(csv_text: str | bytes) -> str:
    if isinstance(csv_text, str):
        data = csv_text.encode("utf-8")
        if len(data) > MAX_UPLOAD_BYTES:
            raise ValueError("CSV-Datei ist zu gross; Maximum ist 5 MB.")
        return csv_text
    if len(csv_text) > MAX_UPLOAD_BYTES:
        raise ValueError("CSV-Datei ist zu gross; Maximum ist 5 MB.")
    for enc in ("utf-8-sig", "cp1252", "latin-1"):
        try:
            return csv_text.decode(enc)
        except UnicodeDecodeError:
            continue
    return csv_text.decode("utf-8", errors="replace")

def _period_from_rows(rows: list[dict[str, Any]]) -> tuple[str | None, str | None]:
    dates: list[str] = []
    for row in rows:
        for key, value in row.items():
            text = str(value or "").strip()
            if re.match(r"^\d{4}-\d{2}-\d{2}", text):
                dates.append(text[:10])
            else:
                m = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})", text)
                if m:
                    d, mo, y = m.groups()
                    dates.append(f"{y}-{int(mo):02d}-{int(d):02d}")
    return (min(dates), max(dates)) if dates else (None, None)

def _review_url(*, import_session_id: str, source_type: str, year: str) -> str:
    return "/planning/budget/expenses/review?" + urlencode({
        "import_session_id": import_session_id,
        "source_type": source_type,
        "year": year,
        "budget_year": year,
        "status": "open",
    })

def _insert_or_update_session(conn: Connection, *, sid: str, source_hash: str, file_name: str, profile: str | None, rows_total: int, totals: dict[str, int], status: str, review_url: str, period: tuple[str | None, str | None]) -> None:
    ts = now()
    source = _source_from_profile(profile, file_name)
    existing = conn.execute("SELECT 1 FROM budget_import_sessions WHERE import_session_id=?", (sid,)).fetchone()
    values = (
        source, source_hash, file_name, None, period[0], period[1], profile, rows_total,
        int(totals.get("new_candidates") or totals.get("candidate_count") or totals.get("would_create_candidate_count") or 0),
        int(totals.get("already_processed") or totals.get("already_processed_count") or 0),
        int(totals.get("possible_duplicates") or totals.get("possible_duplicate_count") or totals.get("duplicate_count") or 0),
        int(totals.get("ignored_count") or 0),
        int(totals.get("covered_by_source") or totals.get("covered_by_source_count") or totals.get("covered_by_migros_count") or 0),
        int(totals.get("error_rows") or totals.get("error_count") or 0),
        status, review_url, ts,
    )
    if existing:
        conn.execute(
            """
            UPDATE budget_import_sessions
            SET source=?, file_id=?, file_name=?, file_modified_time=?, file_period_start=?, file_period_end=?, profile=?, rows_total=?, new_candidates=?, already_known=?, duplicate_count=?, ignored_count=?, covered_by_source_count=?, error_count=?, status=?, review_url=?, updated_at=?
            WHERE import_session_id=?
            """,
            values + (sid,),
        )
    else:
        conn.execute(
            """
            INSERT INTO budget_import_sessions(import_session_id, source, file_id, file_name, file_modified_time, file_period_start, file_period_end, profile, rows_total, new_candidates, already_known, duplicate_count, ignored_count, covered_by_source_count, error_count, status, review_url, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (sid,) + values,
        )

def preview_uploaded_budget_import(conn: Connection, *, filename: str, csv_text: str | bytes, profile: str | None = "auto", default_category_id: str | None = None, income_category_id: str | None = None) -> dict[str, Any]:
    try:
        if not str(filename or "").lower().endswith(".csv"):
            raise ValueError("Nur CSV-Dateien werden unterstützt.")
        text = _decode_csv_text(csv_text)
        digest = hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
        requested_profile = _profile_name(profile)
        parsed = parse_budget_csv_text(text, requested_profile)
        detected_profile = str(parsed["profile"])
        if detected_profile not in IMPORT_PROFILES:
            raise ValueError("Unbekanntes Importprofil.")
        rows = parsed["rows"]
        public_name = _safe_public_file_name(filename, detected_profile, digest)
        archive = _runtime_dir() / f"{digest}.csv"
        if not archive.exists():
            archive.write_text(text, encoding="utf-8")
        preview_id = "bimport_" + digest[:24]
        source_label = f"upload_{digest[:16]}.csv"
        dry = import_budget_csv_text(conn, detected_profile, text, source_file_label=source_label, dry_run=True, default_category_id=default_category_id, income_category_id=income_category_id)
        year = str(((_period_from_rows(rows)[0] or "2026")[:4]) or "2026")
        review = _review_url(import_session_id=preview_id, source_type=IMPORT_PROFILES[detected_profile].source_type, year=year)
        totals = {
            "rows_total": len(rows),
            "new_candidates": int(dry.get("would_create_candidate_count") or 0),
            "already_processed": int(dry.get("already_processed_count") or 0),
            "possible_duplicates": int(dry.get("possible_duplicate_count") or 0),
            "safe_duplicates": int(dry.get("duplicate_count") or 0),
            "error_rows": int(dry.get("error_count") or 0),
            "transfer_candidates": int(dry.get("transfer_candidate_count") or 0),
            "income_candidates": int(dry.get("income_candidate_count") or 0),
            "expense_candidates": max(0, int(dry.get("would_create_candidate_count") or 0) - int(dry.get("income_candidate_count") or 0) - int(dry.get("transfer_candidate_count") or 0)),
            "covered_by_source": int(dry.get("covered_by_source_count") or dry.get("covered_by_migros_count") or 0),
        }
        _insert_or_update_session(conn, sid=preview_id, source_hash=digest, file_name=public_name, profile=detected_profile, rows_total=len(rows), totals=totals, status="dry_run", review_url=review, period=_period_from_rows(rows))
        conn.commit()
        return {
            "purpose": "budget_import_upload_preview_v1",
            "preview_id": preview_id,
            "status": "dry_run",
            "file": {"detected": True, "name": public_name, "source_hash": digest[:16]},
            "profile": detected_profile,
            "source_type": IMPORT_PROFILES[detected_profile].source_type,
            "period": {"start": _period_from_rows(rows)[0], "end": _period_from_rows(rows)[1]},
            "totals": totals,
            "review_url": review,
            "warnings": ["Dry Run: Es wurden keine produktiven Buchungen geschrieben."],
            "errors": [],
        }
    except Exception as exc:
        return {
            "purpose": "budget_import_upload_preview_v1",
            "preview_id": "",
            "status": "failed",
            "file": {"detected": False, "name": _safe_public_file_name(filename, None, "unknown000000")},
            "profile": None,
            "source_type": None,
            "period": {"start": None, "end": None},
            "totals": {"rows_total": 0, "new_candidates": 0, "already_processed": 0, "possible_duplicates": 0, "safe_duplicates": 0, "error_rows": 1, "transfer_candidates": 0, "income_candidates": 0, "expense_candidates": 0, "covered_by_source": 0},
            "review_url": "",
            "warnings": [],
            "errors": [f"Importprofil konnte nicht erkannt werden: {type(exc).__name__}" if "profile" in str(exc).lower() or "Profil" not in str(exc) else str(exc)],
        }

def confirm_uploaded_budget_import_candidates(conn: Connection, preview_id: str, *, default_category_id: str | None = None, income_category_id: str | None = None) -> dict[str, Any]:
    row = conn.execute("SELECT * FROM budget_import_sessions WHERE import_session_id=?", (preview_id,)).fetchone()
    if not row:
        raise ValueError("Import-Preview nicht gefunden; bitte Dry Run erneut starten.")
    digest = str(row["file_id"] or "")
    archive = _runtime_dir() / f"{digest}.csv"
    if not archive.exists():
        raise ValueError("Temporäre Upload-Datei nicht gefunden; bitte Datei erneut hochladen.")
    text = archive.read_text(encoding="utf-8", errors="replace")
    profile = str(row["profile"] or "")
    source_label = f"upload_{digest[:16]}.csv"
    before = int(conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates WHERE import_session_id=?", (preview_id,)).fetchone()[0] or 0)
    result = import_budget_csv_text(conn, profile, text, source_file_label=source_label, dry_run=False, default_category_id=default_category_id, income_category_id=income_category_id)
    conn.execute("UPDATE budget_transaction_candidates SET import_session_id=? WHERE source_file_label=? AND import_session_id IS NULL", (preview_id, source_label))
    after = int(conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates WHERE import_session_id=?", (preview_id,)).fetchone()[0] or 0)
    new_count = max(0, after - before)
    already = int(result.get("already_processed_count") or 0)
    if before and new_count == 0:
        already = max(already, before)
    year = str((row["file_period_start"] or "2026")[:4] or "2026")
    review = _review_url(import_session_id=preview_id, source_type=IMPORT_PROFILES[profile].source_type, year=year)
    totals = {
        "rows_total": int(row["rows_total"] or result.get("parsed_rows") or 0),
        "new_candidates": new_count,
        "already_processed": already,
        "possible_duplicates": int(result.get("possible_duplicate_count") or 0),
        "safe_duplicates": int(result.get("duplicate_count") or 0),
        "error_rows": int(result.get("error_count") or 0),
        "transfer_candidates": int(result.get("transfer_candidate_count") or 0),
        "income_candidates": int(result.get("income_candidate_count") or 0),
        "expense_candidates": max(0, new_count - int(result.get("income_candidate_count") or 0) - int(result.get("transfer_candidate_count") or 0)),
        "covered_by_source": int(result.get("covered_by_source_count") or result.get("covered_by_migros_count") or 0),
    }
    _insert_or_update_session(conn, sid=preview_id, source_hash=digest, file_name=str(row["file_name"]), profile=profile, rows_total=int(row["rows_total"] or 0), totals=totals, status="candidates_created", review_url=review, period=(row["file_period_start"], row["file_period_end"]))
    conn.commit()
    return {"purpose": "budget_import_upload_confirm_candidates_v1", "status": "candidates_created", "import_session_id": preview_id, "profile": profile, "source_type": IMPORT_PROFILES[profile].source_type, "totals": totals, "review_url": review, "warnings": ["Kandidaten erzeugt; Confirm bleibt eine separate User-Aktion."]}

def list_budget_import_sessions(conn: Connection, *, limit: int = 30) -> list[dict[str, Any]]:
    rows = conn.execute("SELECT * FROM budget_import_sessions ORDER BY created_at DESC, import_session_id DESC LIMIT ?", (limit,)).fetchall()
    return [row_to_dict(r) for r in rows]

def get_budget_import_session(conn: Connection, import_session_id: str) -> dict[str, Any]:
    row = conn.execute("SELECT * FROM budget_import_sessions WHERE import_session_id=?", (import_session_id,)).fetchone()
    if not row:
        raise ValueError("Import-Session nicht gefunden")
    data = row_to_dict(row)
    candidates = conn.execute("SELECT status, classification, COUNT(*) AS count FROM budget_transaction_candidates WHERE import_session_id=? GROUP BY status, classification", (import_session_id,)).fetchall()
    data["candidate_groups"] = [row_to_dict(r) for r in candidates]
    return data


Explanation:
**Implementing patch imports**

I need to handle the patch imports for both the route and service. I’m looking specifically at how to manage this with the patch method effectively. It seems straightforward but I want to ensure I'm following best practices. Making the right calls with patch should streamline the process. I’ll keep an eye on the implementation to see if any issues arise along the way. I'm hopeful this will go smoothly!

patch

STATUS_LABELS = {
    "pending": "Offen",
    "auto_categorized": "Sicher vorgeschlagen",
    "needs_review": "Review nötig",
    "confirmed": "Bestätigt",
    "ignored": "Ignoriert",
    "covered_by_source": "Durch Quelle abgedeckt",
    "covered_by_migros": "Durch Migros abgedeckt",
    "duplicate_candidate": "Mögliches Duplikat",
    "duplicate": "Mögliches Duplikat",
    "transfer_candidate": "Möglicher Transfer",
    "superseded": "Ersetzt",
}

USER_OPEN_STATUSES = {"pending", "auto_categorized", "needs_review", "transfer_candidate"}
USER_DUPLICATE_STATUSES = {"duplicate", "duplicate_candidate"}
USER_EXCLUDED_STATUSES = {"confirmed", "ignored", "superseded", "auto_ignored_duplicate", "covered_by_source", "covered_by_migros", "reference_2025", "archived_reference", "already_processed"}
USER_VISIBLE_SPECIAL_STATUSES = USER_DUPLICATE_STATUSES | {"covered_by_source", "covered_by_migros", "already_processed"}

STATUS_LABELS = {
    "pending": "Offen",
    "auto_categorized": "Sicher vorgeschlagen",
    "needs_review": "Review nötig",
    "confirmed": "Bestätigt",
    "ignored": "Ignoriert",
    "covered_by_source": "Durch Quelle abgedeckt",
    "covered_by_migros": "Durch Migros abgedeckt",
    "duplicate_candidate": "Mögliches Duplikat",
    "duplicate": "Mögliches Duplikat",
    "transfer_candidate": "Möglicher Transfer",
    "already_processed": "Bereits verarbeitet",
    "superseded": "Ersetzt",
}


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
@@ -142,6 +142,11 @@
     return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]

+USER_OPEN_STATUSES = {"pending", "auto_categorized", "needs_review", "transfer_candidate"}
+USER_DUPLICATE_STATUSES = {"duplicate", "duplicate_candidate"}
+USER_EXCLUDED_STATUSES = {"confirmed", "ignored", "superseded", "auto_ignored_duplicate", "covered_by_source", "covered_by_migros", "reference_2025", "archived_reference", "already_processed"}
+USER_VISIBLE_SPECIAL_STATUSES = USER_DUPLICATE_STATUSES | {"covered_by_source", "covered_by_migros", "already_processed"}
+
 STATUS_LABELS = {
     "pending": "Offen",
     "auto_categorized": "Sicher vorgeschlagen",
@@ -153,6 +158,7 @@
     "duplicate_candidate": "Mögliches Duplikat",
     "duplicate": "Mögliches Duplikat",
     "transfer_candidate": "Möglicher Transfer",
+    "already_processed": "Bereits verarbeitet",
     "superseded": "Ersetzt",
 }

/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False) -> list[dict[str, Any]]:
    clauses=[]; params=[]
    if budget_year:
        clauses.append("substr(transaction_date,1,4)=?"); params.append(budget_year)
    if status:
        clauses.append("status=?"); params.append(status)
    elif not include_reference and not tab:
        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','superseded','reference_2025','archived_reference')")

def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, import_session_id: str | None = None) -> list[dict[str, Any]]:
    clauses=[]; params=[]
    if budget_year:
        clauses.append("substr(transaction_date,1,4)=?"); params.append(budget_year)
    if status == "open":
        clauses.append("status IN ('pending','auto_categorized','needs_review','transfer_candidate')")
    elif status:
        clauses.append("status=?"); params.append(status)
    elif not include_reference and not tab:
        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','duplicate','superseded','reference_2025','archived_reference','already_processed')")


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
@@ -758,14 +758,16 @@
     return {"candidate_count": created, "income_candidate_count": income, "transfer_candidate_count": transfer, "investment_transfer_count": investment_transfer, "credit_card_payment_count": credit_card_payment, "review_required_count": review, "possible_duplicate_count": dup_count}

-def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False) -> list[dict[str, Any]]:
+def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, import_session_id: str | None = None) -> list[dict[str, Any]]:
     clauses=[]; params=[]
     if budget_year:
         clauses.append("substr(transaction_date,1,4)=?"); params.append(budget_year)
-    if status:
+    if status == "open":
+        clauses.append("status IN ('pending','auto_categorized','needs_review','transfer_candidate')")
+    elif status:
         clauses.append("status=?"); params.append(status)
     elif not include_reference and not tab:
-        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','superseded','reference_2025','archived_reference')")
+        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','duplicate','superseded','reference_2025','archived_reference','already_processed')")
     if tab:
         mapping = {
             "auto": "requires_review=0 AND status IN ('pending','auto_categorized')",


    if source_type:
        clauses.append("source_type=?"); params.append(source_type)
    if import_session_id:
        clauses.append("import_session_id=?"); params.append(import_session_id)
    if category_id:

    if source_type:
        clauses.append("source_type=?"); params.append(source_type)
    if category_id:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
@@ -802,6 +802,8 @@
             clauses.append(mapping[tab])
     if source_type:
         clauses.append("source_type=?"); params.append(source_type)
+    if import_session_id:
+        clauses.append("import_session_id=?"); params.append(import_session_id)
     if category_id:
         clauses.append("proposed_category_id=?"); params.append(category_id)
     if merchant:


def candidate_user_count_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
    rows = conn.execute(
        """
        SELECT status, classification, requires_review, duplicate_of_transaction_id
        FROM budget_transaction_candidates
        WHERE substr(COALESCE(transaction_date,''),1,4)=?
        """,
        (budget_year,),
    ).fetchall()
    def count(pred):
        return sum(1 for r in rows if pred(r))
    normal_open = lambda r: r["status"] in USER_OPEN_STATUSES
    duplicates = lambda r: r["status"] in USER_DUPLICATE_STATUSES or bool(r["duplicate_of_transaction_id"])
    covered = lambda r: r["status"] in {"covered_by_source", "covered_by_migros"}
    already = lambda r: r["status"] in {"already_processed", "auto_ignored_duplicate"}
    return {
        "open_total": count(normal_open),
        "auto_categorized": count(lambda r: r["status"] in {"pending", "auto_categorized"} and not bool(r["requires_review"])),
        "review_needed": count(lambda r: normal_open(r) and bool(r["requires_review"])),
        "galaxus_digitec": count(lambda r: normal_open(r) and r["classification"] == "galaxus_review"),
        "migros_over_50": count(lambda r: normal_open(r) and r["classification"] == "migros_review_over_threshold"),
        "covered_by_source": count(covered),
        "covered_by_migros": count(lambda r: r["status"] == "covered_by_migros"),
        "unclear": count(lambda r: normal_open(r) and r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
        "possible_duplicates": count(duplicates),
        "transfer_candidates": count(lambda r: normal_open(r) and (r["status"] == "transfer_candidate" or r["classification"] == "transfer_candidate")),
        "income_candidates": count(lambda r: normal_open(r) and r["classification"] == "income_candidate"),
        "possible_income": count(lambda r: normal_open(r) and r["classification"] == "possible_income"),
        "already_processed": count(already),
    }

def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
    return candidate_user_count_summary(conn, budget_year=budget_year)

def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
    rows = conn.execute("SELECT status, classification, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirmed','ignored','superseded','reference_2025','archived_reference')", (budget_year,)).fetchall()
    def count(pred):
        return sum(1 for r in rows if pred(r))
    return {
        "open_total": count(lambda r: r["status"] != "covered_by_migros"),
        "auto_categorized": count(lambda r: r["status"] in {"pending", "auto_categorized"} and not bool(r["requires_review"])),
        "review_needed": count(lambda r: bool(r["requires_review"])),
        "galaxus_digitec": count(lambda r: r["classification"] == "galaxus_review"),
        "migros_over_50": count(lambda r: r["classification"] == "migros_review_over_threshold"),
        "covered_by_migros": count(lambda r: r["status"] == "covered_by_migros"),
        "unclear": count(lambda r: r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
        "possible_duplicates": count(lambda r: r["status"] in {"duplicate", "duplicate_candidate"} or bool(r["duplicate_of_transaction_id"])),
    }


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
@@ -1093,20 +1093,40 @@
     return {"confirmed_count": confirmed, "skipped_count": skipped}

-def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
-    rows = conn.execute("SELECT status, classification, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirmed','ignored','superseded','reference_2025','archived_reference')", (budget_year,)).fetchall()
+def candidate_user_count_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
+    rows = conn.execute(
+        """
+        SELECT status, classification, requires_review, duplicate_of_transaction_id
+        FROM budget_transaction_candidates
+        WHERE substr(COALESCE(transaction_date,''),1,4)=?
+        """,
+        (budget_year,),
+    ).fetchall()
     def count(pred):
         return sum(1 for r in rows if pred(r))
+    normal_open = lambda r: r["status"] in USER_OPEN_STATUSES
+    duplicates = lambda r: r["status"] in USER_DUPLICATE_STATUSES or bool(r["duplicate_of_transaction_id"])
+    covered = lambda r: r["status"] in {"covered_by_source", "covered_by_migros"}
+    already = lambda r: r["status"] in {"already_processed", "auto_ignored_duplicate"}
     return {
-        "open_total": count(lambda r: r["status"] != "covered_by_migros"),
+        "open_total": count(normal_open),
         "auto_categorized": count(lambda r: r["status"] in {"pending", "auto_categorized"} and not bool(r["requires_review"])),
-        "review_needed": count(lambda r: bool(r["requires_review"])),
-        "galaxus_digitec": count(lambda r: r["classification"] == "galaxus_review"),
-        "migros_over_50": count(lambda r: r["classification"] == "migros_review_over_threshold"),
+        "review_needed": count(lambda r: normal_open(r) and bool(r["requires_review"])),
+        "galaxus_digitec": count(lambda r: normal_open(r) and r["classification"] == "galaxus_review"),
+        "migros_over_50": count(lambda r: normal_open(r) and r["classification"] == "migros_review_over_threshold"),
+        "covered_by_source": count(covered),
         "covered_by_migros": count(lambda r: r["status"] == "covered_by_migros"),
-        "unclear": count(lambda r: r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
-        "possible_duplicates": count(lambda r: r["status"] in {"duplicate", "duplicate_candidate"} or bool(r["duplicate_of_transaction_id"])),
+        "unclear": count(lambda r: normal_open(r) and r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
+        "possible_duplicates": count(duplicates),
+        "transfer_candidates": count(lambda r: normal_open(r) and (r["status"] == "transfer_candidate" or r["classification"] == "transfer_candidate")),
+        "income_candidates": count(lambda r: normal_open(r) and r["classification"] == "income_candidate"),
+        "possible_income": count(lambda r: normal_open(r) and r["classification"] == "possible_income"),
+        "already_processed": count(already),
     }
+
+
+def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
+    return candidate_user_count_summary(conn, budget_year=budget_year)

 BLOCKED_BULK_EDIT_STATUSES = {"confirmed", "covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference"}


from jarvis_finance.services.budget_imports import _normalise, _normalized_merchant_name, create_budget_rule

from jarvis_finance.services.budget_imports import _normalise, _normalized_merchant_name, candidate_user_count_summary, create_budget_rule


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_monthly_import.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_monthly_import.py
@@ -12,7 +12,7 @@
 from jarvis_finance.audit.log import record_audit_event
 from jarvis_finance.services.budget_common import new_id, now, row_to_dict
 from jarvis_finance.services.budget_csv_imports import detect_import_profile, import_budget_csv_text, parse_budget_csv_text
-from jarvis_finance.services.budget_imports import _normalise, _normalized_merchant_name, create_budget_rule
+from jarvis_finance.services.budget_imports import _normalise, _normalized_merchant_name, candidate_user_count_summary, create_budget_rule

 OPEN_STATUSES = {"pending", "auto_categorized", "ready_for_confirm", "needs_review", "duplicate_candidate", "transfer_candidate"}
 REVIEW_HIDDEN_STATUSES = {"confirmed", "ignored", "covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference"}


def get_monthly_import_dashboard(conn: Connection, *, budget_year: str = "2026") -> dict[str, Any]:
    sessions = get_import_history(conn, limit=100)
    latest_by_source: dict[str, dict[str, Any]] = {}
    for s in sessions:
        latest_by_source.setdefault(str(s.get("source") or "unknown"), s)
    user_counts = candidate_user_count_summary(conn, budget_year=budget_year)
    open_rows = conn.execute("SELECT source_type,status,classification,COUNT(*) AS count FROM budget_transaction_candidates WHERE substr(COALESCE(transaction_date,''),1,4)=? AND status IN ('pending','auto_categorized','needs_review','transfer_candidate') GROUP BY source_type,status,classification", (budget_year,)).fetchall()
    by_source: dict[str, int] = defaultdict(int)
    counts = Counter(user_counts)
    for r in open_rows:
        by_source[str(r["source_type"] or "unknown")] += int(r["count"] or 0)
        if r["status"]:
            counts[str(r["status"])] += int(r["count"] or 0)
        if r["classification"]:
            counts[str(r["classification"])] += int(r["count"] or 0)
    suggestions = conn.execute("SELECT COUNT(*) FROM budget_rule_suggestions WHERE status IN ('suggested','later')").fetchone()[0]
    return {"purpose": "budget_import_dashboard_v2", "workflow": "upload", "latest_import_by_source": latest_by_source, "open_candidates_by_source": dict(by_source), "counts": dict(counts), "user_counts": user_counts, "rule_suggestions_open": int(suggestions or 0), "review_url": _review_url(status="open")}

def get_monthly_import_dashboard(conn: Connection) -> dict[str, Any]:
    sessions = get_import_history(conn, limit=100)
    latest_by_source: dict[str, dict[str, Any]] = {}
    for s in sessions:
        latest_by_source.setdefault(str(s.get("source") or "unknown"), s)
    open_rows = conn.execute("SELECT source_type,status,classification,COUNT(*) AS count FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','reference_2025','archived_reference') GROUP BY source_type,status,classification").fetchall()
    by_source: dict[str, int] = defaultdict(int)
    counts = Counter()
    for r in open_rows:
        by_source[str(r["source_type"] or "unknown")] += int(r["count"] or 0)
        if r["status"]:
            counts[str(r["status"])] += int(r["count"] or 0)
        if r["classification"]:
            counts[str(r["classification"])] += int(r["count"] or 0)
    suggestions = conn.execute("SELECT COUNT(*) FROM budget_rule_suggestions WHERE status IN ('suggested','later')").fetchone()[0]
    return {"purpose": "budget_monthly_import_dashboard_v1", "latest_import_by_source": latest_by_source, "open_candidates_by_source": dict(by_source), "counts": dict(counts), "rule_suggestions_open": int(suggestions or 0), "review_url": _review_url(status="needs_review")}


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_monthly_import.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_monthly_import.py
@@ -152,14 +152,15 @@
     return [row_to_dict(r) for r in rows]

-def get_monthly_import_dashboard(conn: Connection) -> dict[str, Any]:
+def get_monthly_import_dashboard(conn: Connection, *, budget_year: str = "2026") -> dict[str, Any]:
     sessions = get_import_history(conn, limit=100)
     latest_by_source: dict[str, dict[str, Any]] = {}
     for s in sessions:
         latest_by_source.setdefault(str(s.get("source") or "unknown"), s)
-    open_rows = conn.execute("SELECT source_type,status,classification,COUNT(*) AS count FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','reference_2025','archived_reference') GROUP BY source_type,status,classification").fetchall()
+    user_counts = candidate_user_count_summary(conn, budget_year=budget_year)
+    open_rows = conn.execute("SELECT source_type,status,classification,COUNT(*) AS count FROM budget_transaction_candidates WHERE substr(COALESCE(transaction_date,''),1,4)=? AND status IN ('pending','auto_categorized','needs_review','transfer_candidate') GROUP BY source_type,status,classification", (budget_year,)).fetchall()
     by_source: dict[str, int] = defaultdict(int)
-    counts = Counter()
+    counts = Counter(user_counts)
     for r in open_rows:
         by_source[str(r["source_type"] or "unknown")] += int(r["count"] or 0)
         if r["status"]:
@@ -167,7 +168,7 @@
         if r["classification"]:
             counts[str(r["classification"])] += int(r["count"] or 0)
     suggestions = conn.execute("SELECT COUNT(*) FROM budget_rule_suggestions WHERE status IN ('suggested','later')").fetchone()[0]
-    return {"purpose": "budget_monthly_import_dashboard_v1", "latest_import_by_source": latest_by_source, "open_candidates_by_source": dict(by_source), "counts": dict(counts), "rule_suggestions_open": int(suggestions or 0), "review_url": _review_url(status="needs_review")}
+    return {"purpose": "budget_import_dashboard_v2", "workflow": "upload", "latest_import_by_source": latest_by_source, "open_candidates_by_source": dict(by_source), "counts": dict(counts), "user_counts": user_counts, "rule_suggestions_open": int(suggestions or 0), "review_url": _review_url(status="open")}

 def _candidate(conn: Connection, candidate_id: str):


from jarvis_finance.services.budget_import_upload import (
    confirm_uploaded_budget_import_candidates,
    get_budget_import_session,
    list_budget_import_sessions,
    preview_uploaded_budget_import,
)
from jarvis_finance.services.budget_imports import (
    archive_reference_year_candidates,


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -24,6 +24,12 @@
     update_category,
 )
 from jarvis_finance.services.budget_csv_imports import import_budget_csv_text, import_profile_specs
+from jarvis_finance.services.budget_import_upload import (
+    confirm_uploaded_budget_import_candidates,
+    get_budget_import_session,
+    list_budget_import_sessions,
+    preview_uploaded_budget_import,
+)
 from jarvis_finance.services.budget_imports import (
     archive_reference_year_candidates,
     apply_budget_rules_to_open_candidates,

/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


@router.post("/import/upload/preview")
def budget_import_upload_preview(payload: dict, conn: Connection = Depends(get_db)):
    return preview_uploaded_budget_import(conn, filename=str(payload.get("filename") or "budget.csv"), csv_text=str(payload.get("csv_text") or ""), profile=payload.get("profile") or "auto", default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))

@router.post("/import/upload/confirm-candidates")
def budget_import_upload_confirm_candidates(payload: dict, conn: Connection = Depends(get_db)):
    return confirm_uploaded_budget_import_candidates(conn, str(payload.get("preview_id") or payload.get("import_session_id") or ""), default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))

@router.get("/import/sessions")
def budget_import_sessions(limit: int = 30, conn: Connection = Depends(get_db)):
    return list_budget_import_sessions(conn, limit=limit)

@router.get("/import/sessions/{import_session_id}")
def budget_import_session(import_session_id: str, conn: Connection = Depends(get_db)):
    return get_budget_import_session(conn, import_session_id)

@router.get("/transaction-candidates")
def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, import_session_id: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, import_session_id=import_session_id, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)

@router.get("/transaction-candidates")
def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -590,9 +590,29 @@
     return import_budget_csv_text(conn, profile_name, str(payload.get("csv_text") or ""), source_file_label=payload.get("source_file_label") or f"{profile_name}.csv", dry_run=False, default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))

+@router.post("/import/upload/preview")
+def budget_import_upload_preview(payload: dict, conn: Connection = Depends(get_db)):
+    return preview_uploaded_budget_import(conn, filename=str(payload.get("filename") or "budget.csv"), csv_text=str(payload.get("csv_text") or ""), profile=payload.get("profile") or "auto", default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
+
+
+@router.post("/import/upload/confirm-candidates")
+def budget_import_upload_confirm_candidates(payload: dict, conn: Connection = Depends(get_db)):
+    return confirm_uploaded_budget_import_candidates(conn, str(payload.get("preview_id") or payload.get("import_session_id") or ""), default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
+
+
+@router.get("/import/sessions")
+def budget_import_sessions(limit: int = 30, conn: Connection = Depends(get_db)):
+    return list_budget_import_sessions(conn, limit=limit)
+
+
+@router.get("/import/sessions/{import_session_id}")
+def budget_import_session(import_session_id: str, conn: Connection = Depends(get_db)):
+    return get_budget_import_session(conn, import_session_id)
+
+
 @router.get("/transaction-candidates")
-def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
-    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)
+def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, import_session_id: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
+    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, import_session_id=import_session_id, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)

 @router.get("/transaction-candidates/summary")


Explanation:
**Considering dashboard routes**

I'm thinking about whether a monthly dashboard route should also accept the year as a parameter. If it does, I might need to look into patching around the endpoints to ensure everything works smoothly. It sounds like there could be a few details to refine, but it’s definitely worth considering. I want to make sure that the user has everything they need for a comprehensive view! Let's explore that further.

from pathlib import Path
p=Path('/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py')
for i,l in enumerate(p.read_text().splitlines(),1):
    if 'monthly-import' in l:
        print(i,l)


Output:
253 @router.get("/monthly-import/dashboard")
258 @router.get("/monthly-import/history")
263 @router.post("/monthly-import/drive-scan")
269 @router.post("/monthly-import/dry-run")
274 @router.post("/monthly-import/confirm-candidates")


   247|
   248|@router.post("/grocery-optimizer/report")
   249|def grocery_optimizer_report(payload: dict, conn: Connection = Depends(get_db)):
   250|    return generate_grocery_optimization_report(conn, payload["run_id"])
   251|
   252|
   253|@router.get("/monthly-import/dashboard")
   254|def monthly_import_dashboard(conn: Connection = Depends(get_db)):
   255|    return get_monthly_import_dashboard(conn)
   256|
   257|
   258|@router.get("/monthly-import/history")
   259|def monthly_import_history(limit: int = 30, conn: Connection = Depends(get_db)):
   260|    return get_import_history(conn, limit=limit)
   261|
   262|
   263|@router.post("/monthly-import/drive-scan")
   264|def monthly_import_drive_scan(payload: dict | None = None, conn: Connection = Depends(get_db)):
   265|    files = (payload or {}).get("files")
   266|    return scan_budget_drive_files(files) if files is not None else scan_google_drive_budget_folder()
   267|
   268|
   269|@router.post("/monthly-import/dry-run")
   270|def monthly_import_dry_run(payload: dict, conn: Connection = Depends(get_db)):
   271|    return run_monthly_import_dry_run(conn, payload.get("files") or [], default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   272|
   273|
   274|@router.post("/monthly-import/confirm-candidates")
   275|def monthly_import_confirm_candidates(payload: dict, conn: Connection = Depends(get_db)):
   276|    return preview_drive_monthly_import(conn, payload.get("files") or [], dry_run=False, default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   277|
   278|
   279|@router.get("/rules/suggestions")
   280|def budget_rule_suggestions(status: str | None = None, conn: Connection = Depends(get_db)):
   281|    return list_rule_suggestions(conn, status=status)
   282|
   283|
   284|@router.post("/rules/suggestions/{suggestion_id}/apply")
   285|def budget_rule_suggestion_apply(suggestion_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   286|    return apply_rule_suggestion(conn, suggestion_id, apply_to_open=bool((payload or {}).get("apply_to_open")))
   287|

Use offset=287 to continue reading (showing 247-286 of 958 lines)


@router.get("/monthly-import/dashboard")
def monthly_import_dashboard(budget_year: str = "2026", conn: Connection = Depends(get_db)):
    return get_monthly_import_dashboard(conn, budget_year=budget_year)

@router.get("/monthly-import/dashboard")
def monthly_import_dashboard(conn: Connection = Depends(get_db)):
    return get_monthly_import_dashboard(conn)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -251,8 +251,8 @@

 @router.get("/monthly-import/dashboard")
-def monthly_import_dashboard(conn: Connection = Depends(get_db)):
-    return get_monthly_import_dashboard(conn)
+def monthly_import_dashboard(budget_year: str = "2026", conn: Connection = Depends(get_db)):
+    return get_monthly_import_dashboard(conn, budget_year=budget_year)

 @router.get("/monthly-import/history")


terminal

{"output": "FFF.                                                                     [100%]\n=================================== FAILURES ===================================\n__ test_upload_preview_detects_supported_profiles_without_writing_candidates ___\n\n    def test_upload_preview_detects_supported_profiles_without_writing_candidates() -> None:\n        conn = db(); _account, expense, income = setup_base(conn)\n        cases = [\n            (\"visa.csv\", VISA_CSV, \"visa_credit_card\"),\n            (\"migros.csv\", MIGROS_CSV, \"migros_receipts\"),\n            (\"raiffeisen.csv\", RAIFFEISEN_CSV, \"raiffeisen_bank\"),\n            (\"akb.csv\", AKB_CSV, \"akb_bank\"),\n        ]\n    \n        for filename, csv_text, profile in cases:\n            result = preview_uploaded_budget_import(conn, filename=filename, csv_text=csv_text, profile=\"auto\", default_category_id=expense, income_category_id=income)\n>           assert result[\"status\"] == \"dry_run\"\nE           AssertionError: assert 'failed' == 'dry_run'\nE             \nE             - dry_run\nE             + failed\n\ntests/unit/test_budget_import_upload_ux_v1.py:47: AssertionError\n_____________ test_upload_unknown_csv_returns_understandable_error _____________\n\n    def test_upload_unknown_csv_returns_understandable_error() -> None:\n        conn = db(); setup_base(conn)\n    \n        result = preview_uploaded_budget_import(conn, filename=\"unknown.csv\", csv_text=\"foo;bar\\n1;2\\n\", profile=\"auto\")\n    \n        assert result[\"status\"] == \"failed\"\n        assert result[\"errors\"]\n>       assert \"Profil\" in result[\"errors\"][0] or \"profile\" in result[\"errors\"][0].lower()\nE       AssertionError: assert ('Profil' in 'Importprofil konnte nicht erkannt werden: ValueError' or 'profile' in 'importprofil konnte nicht erkannt werden: valueerror')\nE        +  where 'importprofil konnte nicht erkannt werden: valueerror' = <built-in method lower of str object at 0x7eae5eaa9ca0>()\nE        +    where <built-in method lower of str object at 0x7eae5eaa9ca0> = 'Importprofil konnte nicht erkannt werden: ValueError'.lower\n\ntests/unit/test_budget_import_upload_ux_v1.py:63: AssertionError\n_ test_upload_confirm_creates_candidates_idempotently_and_links_review_filter __\n\n    def test_upload_confirm_creates_candidates_idempotently_and_links_review_filter() -> None:\n        conn = db(); _account, expense, income = setup_base(conn)\n        preview = preview_uploaded_budget_import(conn, filename=\"visa.csv\", csv_text=VISA_CSV, profile=\"auto\", default_category_id=expense, income_category_id=income)\n    \n>       first = confirm_uploaded_budget_import_candidates(conn, preview[\"preview_id\"])\n                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\ntests/unit/test_budget_import_upload_ux_v1.py:71: \n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \n\nconn = <sqlite3.Connection object at 0x7eae5ec25b70>, preview_id = ''\n\n    def confirm_uploaded_budget_import_candidates(conn: Connection, preview_id: str, *, default_category_id: str | None = None, income_category_id: str | None = None) -> dict[str, Any]:\n        row = conn.execute(\"SELECT * FROM budget_import_sessions WHERE import_session_id=?\", (preview_id,)).fetchone()\n        if not row:\n>           raise ValueError(\"Import-Preview nicht gefunden; bitte Dry Run erneut starten.\")\nE           ValueError: Import-Preview nicht gefunden; bitte Dry Run erneut starten.\n\nsrc/jarvis_finance/services/budget_import_upload.py:195: ValueError\n=========================== short test summary info ============================\nFAILED tests/unit/test_budget_import_upload_ux_v1.py::test_upload_preview_detects_supported_profiles_without_writing_candidates\nFAILED tests/unit/test_budget_import_upload_ux_v1.py::test_upload_unknown_csv_returns_understandable_error\nFAILED tests/unit/test_budget_import_upload_ux_v1.py::test_upload_confirm_creates_candidates_idempotently_and_links_review_filter\n3 failed, 1 passed in 0.52s", "exit_code": 1, "error": null}

[Tool loop warning: repeated_exact_failure_warning; count=2; terminal has failed 2 times with identical arguments. This looks like a loop; inspect the error and change strategy instead of retrying it unchanged.]


RED→GREEN zeigt konkrete Kante: Preview scheitert intern. Ich inspiziere den Exception-Pfad ohne Rohdaten auszugeben.

import sys, traceback
sys.path.insert(0,'/home/agent/.hermes/repos/FinanceManager/src')
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations
from jarvis_finance.services.budget_accounts import confirm_create_budget_account
from jarvis_finance.services.budget_categories import confirm_create_category
from jarvis_finance.services.budget_import_upload import preview_uploaded_budget_import
conn=connect_memory(); apply_migrations(conn)
exp=confirm_create_category(conn, {'name':'Essen & Haushalt','category_type':'expense'})['entity_id']
inc=confirm_create_category(conn, {'name':'Lohn Marcel','category_type':'income'})['entity_id']
for name, txt in [('visa.csv',"TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nT1,C1,2026-05-01,-10.00,CHF,Netflix,Abo\n"),('migros.csv',"Datum;Zeit;Filiale;Kassennummer;Transaktionsnummer;Artikel;Menge;Aktion;Umsatz\n02.05.2026;10:00;Migros Test;1;99;Artikel;1;;1.00\n")]:
 print(name, preview_uploaded_budget_import(conn, filename=name, csv_text=txt, profile='auto', default_category_id=exp, income_category_id=inc))


Output:
visa.csv {'purpose': 'budget_import_upload_preview_v1', 'preview_id': '', 'status': 'failed', 'file': {'detected': False, 'name': 'visa_upload_unknown00000.csv'}, 'profile': None, 'source_type': None, 'period': {'start': None, 'end': None}, 'totals': {'rows_total': 0, 'new_candidates': 0, 'already_processed': 0, 'possible_duplicates': 0, 'safe_duplicates': 0, 'error_rows': 1, 'transfer_candidates': 0, 'income_candidates': 0, 'expense_candidates': 0, 'covered_by_source': 0}, 'review_url': '', 'warnings': [], 'errors': ['Importprofil konnte nicht erkannt werden: ProgrammingError']}
migros.csv {'purpose': 'budget_import_upload_preview_v1', 'preview_id': '', 'status': 'failed', 'file': {'detected': False, 'name': 'migros_upload_unknown00000.csv'}, 'profile': None, 'source_type': None, 'period': {'start': None, 'end': None}, 'totals': {'rows_total': 0, 'new_candidates': 0, 'already_processed': 0, 'possible_duplicates': 0, 'safe_duplicates': 0, 'error_rows': 1, 'transfer_candidates': 0, 'income_candidates': 0, 'expense_candidates': 0, 'covered_by_source': 0}, 'review_url': '', 'warnings': [], 'errors': ['Importprofil konnte nicht erkannt werden: ProgrammingError']}


patch

    values = (
        source, source_hash, file_name, None, period[0], period[1], profile, rows_total,
        int(totals.get("new_candidates") or totals.get("candidate_count") or totals.get("would_create_candidate_count") or 0),
        int(totals.get("already_processed") or totals.get("already_processed_count") or 0),
        int(totals.get("possible_duplicates") or totals.get("possible_duplicate_count") or totals.get("duplicate_count") or 0),
        int(totals.get("ignored_count") or 0),
        int(totals.get("covered_by_source") or totals.get("covered_by_source_count") or totals.get("covered_by_migros_count") or 0),
        int(totals.get("error_rows") or totals.get("error_count") or 0),
        status, review_url, ts,
    )


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_import_upload.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_import_upload.py
@@ -121,7 +121,7 @@
             INSERT INTO budget_import_sessions(import_session_id, source, file_id, file_name, file_modified_time, file_period_start, file_period_end, profile, rows_total, new_candidates, already_known, duplicate_count, ignored_count, covered_by_source_count, error_count, status, review_url, created_at, updated_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
             """,
-            (sid,) + values,
+            (sid,) + values[:-1] + (ts, ts),
         )


Explanation:
**Adjusting for capitalization errors**

I discovered an error related to capitalization in my recent input about "Profil." It looks like there's a mismatch between the uppercase "Profil" and the lowercase "importprofil." I need to make sure they match in terms of capitalization. Adjusting this should resolve the issue and keep everything consistent. It's always important to pay attention to these details to avoid confusion. Let's fix it!

"errors": [f"Import-Profil konnte nicht erkannt werden: {type(exc).__name__}"],

"errors": [f"Importprofil konnte nicht erkannt werden: {type(exc).__name__}" if "profile" in str(exc).lower() or "Profil" not in str(exc) else str(exc)],


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_import_upload.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_import_upload.py
@@ -185,7 +185,7 @@
             "totals": {"rows_total": 0, "new_candidates": 0, "already_processed": 0, "possible_duplicates": 0, "safe_duplicates": 0, "error_rows": 1, "transfer_candidates": 0, "income_candidates": 0, "expense_candidates": 0, "covered_by_source": 0},
             "review_url": "",
             "warnings": [],
-            "errors": [f"Importprofil konnte nicht erkannt werden: {type(exc).__name__}" if "profile" in str(exc).lower() or "Profil" not in str(exc) else str(exc)],
+            "errors": [f"Import-Profil konnte nicht erkannt werden: {type(exc).__name__}"],
         }


terminal

Output:
....                                                                     [100%]
4 passed in 0.46s


Backend-RED ist grün: Upload Preview/Confirm, Idempotenz, Session-Filter und Count-Policy. Jetzt Frontend-RED für Import-Seite/Nav/Drive-Degradation.

Explanation:
**Considering testing needs**

I need to assess whether the existing tests need to be broken down or if they’re sufficient. It seems like I should focus on creating UI tests as well. Writing frontend tests is definitely part of the plan, but I want to ensure they’re done in a consistent style. It’s about finding the right balance between thoroughness and clarity in how I approach the testing process.


     1|import { mount, flushPromises } from '@vue/test-utils'
     2|import { beforeEach, describe, expect, it, vi } from 'vitest'
     3|import BudgetMonthlyImportPage from './BudgetMonthlyImportPage.vue'
     4|import { confirmBudgetMonthlyImportCandidates, dryRunBudgetMonthlyImport, getBudgetImportHistory, getBudgetMonthlyImportDashboard } from '@/api/budget'
     5|
     6|vi.mock('@/api/budget', () => ({
     7|  getBudgetMonthlyImportDashboard: vi.fn(),
     8|  getBudgetImportHistory: vi.fn(),
     9|  scanBudgetMonthlyImportDrive: vi.fn().mockResolvedValue({ files: [] }),
    10|  dryRunBudgetMonthlyImport: vi.fn(),
    11|  confirmBudgetMonthlyImportCandidates: vi.fn()
    12|}))
    13|
    14|beforeEach(() => {
    15|  vi.clearAllMocks()
    16|  vi.mocked(getBudgetMonthlyImportDashboard).mockResolvedValue({ purpose: 'budget_monthly_import_dashboard_v1', latest_import_by_source: { VISA: { import_session_id: 's1', source: 'VISA', file_name: 'VISA.csv', profile: 'visa_credit_card', rows_total: 2, new_candidates: 1, already_known: 1, duplicate_count: 0, ignored_count: 0, covered_by_source_count: 0, error_count: 0, status: 'candidates_created', review_url: '/planning/budget/expenses/review?source=VISA', created_at: '2026-05-19T10:00:00Z' } }, open_candidates_by_source: { visa_credit_card: 2 }, counts: { ready_for_confirm: 1, needs_review: 1 }, rule_suggestions_open: 1, review_url: '/planning/budget/expenses/review?status=needs_review' } as any)
    17|  vi.mocked(getBudgetImportHistory).mockResolvedValue([{ import_session_id: 's1', source: 'VISA', file_name: 'VISA.csv', profile: 'visa_credit_card', rows_total: 2, new_candidates: 1, already_known: 1, duplicate_count: 0, ignored_count: 0, covered_by_source_count: 0, error_count: 0, status: 'candidates_created', review_url: '/planning/budget/expenses/review?source=VISA', created_at: '2026-05-19T10:00:00Z' }] as any)
    18|  vi.mocked(dryRunBudgetMonthlyImport).mockResolvedValue({ purpose: 'budget_monthly_import_workflow_v1', status: 'dry_run', files_total: 1, session_ids: ['s2'], files: [], totals: { would_create_candidate_count: 1 }, review_url: '/planning/budget/expenses/review?status=ready_for_confirm' } as any)
    19|  vi.mocked(confirmBudgetMonthlyImportCandidates).mockResolvedValue({ purpose: 'budget_monthly_import_workflow_v1', status: 'candidates_created', files_total: 1, session_ids: ['s3'], files: [], totals: { candidate_count: 1 }, review_url: '/planning/budget/expenses/review?status=ready_for_confirm' } as any)
    20|})
    21|
    22|describe('BudgetMonthlyImportPage', () => {
    23|  it('renders dashboard, import history and review transition without auto booking', async () => {
    24|    const wrapper = mount(BudgetMonthlyImportPage)
    25|    await flushPromises()
    26|    expect(wrapper.text()).toContain('Budget Monthly Import')
    27|    expect(wrapper.text()).toContain('Monatsimport Dashboard')
    28|    expect(wrapper.text()).toContain('Import-Historie')
    29|    expect(wrapper.text()).toContain('Ready for Confirm')
    30|    expect(wrapper.html()).toContain('/planning/budget/expenses/review')
    31|    await (wrapper.vm as any).dryRunDemo()
    32|    expect(dryRunBudgetMonthlyImport).toHaveBeenCalled()
    33|    expect(wrapper.text()).toContain('keine Kandidaten geschrieben')
    34|    await (wrapper.vm as any).createDemoCandidates()
    35|    expect(confirmBudgetMonthlyImportCandidates).toHaveBeenCalled()
    36|    expect(wrapper.text()).toContain('Review/Confirm bleibt getrennt')
    37|  })
    38|})
    39|


Explanation:
**Preparing for testing**

I'm thinking about adapting a test or maybe creating a new one. There's a need to implement the first red test, specifically for the BudgetImportPage. I wonder if I need to mock the file text for this. It might be simpler to expose the functions. I'll have to consider how to set this up effectively, to ensure everything functions as expected in the test environment. Let's make sure the process is smooth!

import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BudgetImportPage from './BudgetImportPage.vue'
import { confirmBudgetUploadImportCandidates, getBudgetImportSessions, previewBudgetUploadImport } from '@/api/budget'

vi.mock('@/api/budget', () => ({
  previewBudgetUploadImport: vi.fn(),
  confirmBudgetUploadImportCandidates: vi.fn(),
  getBudgetImportSessions: vi.fn(),
}))

beforeEach(() => {
  vi.clearAllMocks()
  vi.mocked(getBudgetImportSessions).mockResolvedValue([] as any)
  vi.mocked(previewBudgetUploadImport).mockResolvedValue({
    purpose: 'budget_import_upload_preview_v1',
    preview_id: 'bimport_123',
    status: 'dry_run',
    file: { detected: true, name: 'visa_upload_abc.csv', source_hash: 'abc' },
    profile: 'visa_credit_card',
    source_type: 'visa_credit_card',
    period: { start: '2026-05-01', end: '2026-05-01' },
    totals: { rows_total: 1, new_candidates: 1, already_processed: 0, possible_duplicates: 0, safe_duplicates: 0, error_rows: 0, transfer_candidates: 0, income_candidates: 0, expense_candidates: 1, covered_by_source: 0 },
    review_url: '/planning/budget/expenses/review?import_session_id=bimport_123&source_type=visa_credit_card&year=2026&status=open',
    warnings: ['Dry Run'],
    errors: [],
  } as any)
  vi.mocked(confirmBudgetUploadImportCandidates).mockResolvedValue({
    purpose: 'budget_import_upload_confirm_candidates_v1',
    status: 'candidates_created',
    import_session_id: 'bimport_123',
    profile: 'visa_credit_card',
    source_type: 'visa_credit_card',
    totals: { rows_total: 1, new_candidates: 1, already_processed: 0, possible_duplicates: 0, safe_duplicates: 0, error_rows: 0, transfer_candidates: 0, income_candidates: 0, expense_candidates: 1, covered_by_source: 0 },
    review_url: '/planning/budget/expenses/review?import_session_id=bimport_123&source_type=visa_credit_card&year=2026&status=open',
    warnings: ['Confirm bleibt separat'],
  } as any)
})

describe('BudgetImportPage', () => {
  it('renders upload workflow and aggregated dry-run counts without Google Drive as primary flow', async () => {
    const wrapper = mount(BudgetImportPage)
    await flushPromises()

    expect(wrapper.text()).toContain('Budget Import')
    expect(wrapper.text()).toContain('Datei hochladen')
    expect(wrapper.text()).toContain('Profil automatisch erkennen')
    expect(wrapper.text()).toContain('VISA')
    expect(wrapper.text()).toContain('Migros')
    expect(wrapper.text()).toContain('Raiffeisen')
    expect(wrapper.text()).toContain('AKB')
    expect(wrapper.text()).toContain('Dry Run starten')
    expect(wrapper.text()).not.toContain('Google-Drive-CSV Workflow')
  })

  it('shows dry-run result, enables candidate creation, and builds review link with session filters', async () => {
    const wrapper = mount(BudgetImportPage)
    await flushPromises()

    ;(wrapper.vm as any).selectedFileName = 'visa.csv'
    ;(wrapper.vm as any).csvText = 'TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nT1,C1,2026-05-01,-10.00,CHF,Netflix,Abo\n'
    await (wrapper.vm as any).startDryRun()
    await flushPromises()

    expect(previewBudgetUploadImport).toHaveBeenCalledWith(expect.objectContaining({ filename: 'visa.csv', profile: 'auto' }))
    expect(wrapper.text()).toContain('Zeilen total')
    expect(wrapper.text()).toContain('neue Kandidaten')
    expect(wrapper.text()).toContain('bereits verarbeitet')

    await (wrapper.vm as any).createCandidates()
    await flushPromises()

    expect(confirmBudgetUploadImportCandidates).toHaveBeenCalledWith(expect.objectContaining({ preview_id: 'bimport_123' }))
    expect(wrapper.html()).toContain('import_session_id=bimport_123')
    expect(wrapper.html()).toContain('status=open')
    expect(wrapper.text()).toContain('Zu Buchungen prüfen')
  })
})


npx not usable: This is not the tsc command you are looking for

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportPage.test.ts">
ERROR [3:30] Cannot find module './BudgetImportPage.vue' or its corresponding type declarations. [2307] (typescript)
ERROR [4:10] '"@/api/budget"' has no exported member named 'confirmBudgetUploadImportCandidates'. Did you mean 'confirmBudgetMonthlyImportCandidates'? [2724] (typescript)
ERROR [4:47] '"@/api/budget"' has no exported member named 'getBudgetImportSessions'. Did you mean 'BudgetImportSession'? [2724] (typescript)
ERROR [4:72] Module '"@/api/budget"' has no exported member 'previewBudgetUploadImport'. [2305] (typescript)
</diagnostics>


npm test -- --run src/pages/BudgetImportPage.test.ts


Output:
> jarvis-finance-dashboard-vue@0.0.0 test
> vitest run --environment jsdom --run src/pages/BudgetImportPage.test.ts

 RUN  v4.1.6 /home/agent/.hermes/repos/FinanceManager/frontend

 ❯ src/pages/BudgetImportPage.test.ts (0 test)

⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/pages/BudgetImportPage.test.ts [ src/pages/BudgetImportPage.test.ts ]
Error: Failed to resolve import "./BudgetImportPage.vue" from "src/pages/BudgetImportPage.test.ts". Does the file exist?
  Plugin: vite:import-analysis
  File: /home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportPage.test.ts:3:0
  5  |  }));
  6  |  const __vi_import_0__ = await import("@vue/test-utils");
  7  |  const __vi_import_1__ = await import("./BudgetImportPage.vue");
     |                                       ^
  8  |  const __vi_import_2__ = await import("@/api/budget");
  9  |
 ❯ TransformPluginContext._formatLog node_modules/vite/dist/node/chunks/node.js:30486:39
 ❯ TransformPluginContext.error node_modules/vite/dist/node/chunks/node.js:30483:14
 ❯ normalizeUrl node_modules/vite/dist/node/chunks/node.js:27725:18
 ❯ node_modules/vite/dist/node/chunks/node.js:27788:30
 ❯ TransformPluginContext.transform node_modules/vite/dist/node/chunks/node.js:27756:4
 ❯ EnvironmentPluginContainer.transform node_modules/vite/dist/node/chunks/node.js:30271:14
 ❯ loadAndTransform node_modules/vite/dist/node/chunks/node.js:24532:26

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

 Test Files  1 failed (1)
      Tests  no tests
   Start at  20:06:21
   Duration  857ms (transform 22ms, setup 140ms, import 0ms, tests 0ms, environment 547ms)


Frontend-RED bestätigt. Ich baue jetzt die neue `/planning/budget/import`-Seite, API-Client-Funktionen und Navigation; der alte Monatsimport bleibt Admin/Fallback, aber verschwindet aus User-Navigation.

Explanation:
**Implementing front-end updates**

I’m working on implementing the front-end API, page, and route navigation. I want to use the existing styles for consistency. It might also be a good idea to update the monthly page to degrade properly — perhaps I could replace the content or just leave the admin intact. I’ll figure out new navigation routes and add the necessary functions to enhance everything. This should help keep things running smoothly.

patch

export type BudgetUploadImportResult = { purpose: string; preview_id?: string; import_session_id?: string; status: string; file?: { detected: boolean; name: string; source_hash?: string }; profile?: string | null; source_type?: string | null; period?: { start?: string | null; end?: string | null }; totals: Record<string, number>; review_url: string; warnings?: string[]; errors?: string[] }
export type BudgetRuleSuggestion = { suggestion_id: string; merchant_pattern: string; match_type: string; source_scope: string; category_id?: string | null; category_name?: string | null; recurring_type?: string | null; confidence: string; affected_open_candidate_count: number; status: string; created_at: string }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -15,6 +15,7 @@
 export type BudgetImportSession = { import_session_id: string; source?: string; file_name?: string; profile?: string; rows_total: number; new_candidates: number; already_known: number; duplicate_count: number; ignored_count: number; covered_by_source_count: number; error_count: number; status: string; review_url?: string; created_at: string }
 export type BudgetMonthlyImportDashboard = { purpose: 'budget_monthly_import_dashboard_v1'; latest_import_by_source: Record<string, BudgetImportSession>; open_candidates_by_source: Record<string, number>; counts: Record<string, number>; rule_suggestions_open: number; review_url: string }
 export type BudgetMonthlyImportResult = { purpose: string; status: string; files_total: number; session_ids: string[]; files: Array<Record<string, unknown>>; totals: Record<string, number>; review_url: string }
+export type BudgetUploadImportResult = { purpose: string; preview_id?: string; import_session_id?: string; status: string; file?: { detected: boolean; name: string; source_hash?: string }; profile?: string | null; source_type?: string | null; period?: { start?: string | null; end?: string | null }; totals: Record<string, number>; review_url: string; warnings?: string[]; errors?: string[] }
 export type BudgetRuleSuggestion = { suggestion_id: string; merchant_pattern: string; match_type: string; source_scope: string; category_id?: string | null; category_name?: string | null; recurring_type?: string | null; confidence: string; affected_open_candidate_count: number; status: string; created_at: string }
 export type GroceryMapping = { mapping_id: string; target_retailer: string; target_product_name: string; target_product_url: string; status: string; match_type?: string; confidence?: string; cache_status?: string; last_price_checked_at?: string; target_price_text?: string; target_unit_price_text?: string; target_price_date?: string; target_price_source?: string }
 export type GroceryManualMappingPreview = { preview_id: string; product_item_id: string; target_retailer: string; target_product_name: string; target_product_url: string; target_package_size?: string; target_unit?: string; candidate_price_text?: string; candidate_unit_price_text?: string; target_price_date?: string; target_price_source?: string; quality_flags: string[]; can_count_as_secure_saving: boolean; requires_confirm: boolean }

Output: error TS5112: tsconfig.json is present but will not be loaded if files are specified on commandline. Use '--ignoreConfig' to skip this error.

Pre-existing lint errors — this edit didn't introduce new ones but the file is still broken.


export const getBudgetImportHistory = () => apiGet<BudgetImportSession[]>('/api/budget/monthly-import/history')
export const getBudgetImportSessions = () => apiGet<BudgetImportSession[]>('/api/budget/import/sessions')
export const previewBudgetUploadImport = (body: unknown) => apiPost<BudgetUploadImportResult>('/api/budget/import/upload/preview', body)
export const confirmBudgetUploadImportCandidates = (body: unknown) => apiPost<BudgetUploadImportResult>('/api/budget/import/upload/confirm-candidates', body)
export const scanBudgetMonthlyImportDrive = (body: unknown = {}) => apiPost<Record<string, unknown>>('/api/budget/monthly-import/drive-scan', body)

export const getBudgetImportHistory = () => apiGet<BudgetImportSession[]>('/api/budget/monthly-import/history')
export const scanBudgetMonthlyImportDrive = (body: unknown = {}) => apiPost<Record<string, unknown>>('/api/budget/monthly-import/drive-scan', body)


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -87,6 +87,9 @@
 export const runGroceryOptimization = (body: unknown) => apiPost<GroceryRun>('/api/budget/grocery-optimizer/optimize', body)
 export const generateGroceryOptimizationReport = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/report', body)
 export const getBudgetImportHistory = () => apiGet<BudgetImportSession[]>('/api/budget/monthly-import/history')
+export const getBudgetImportSessions = () => apiGet<BudgetImportSession[]>('/api/budget/import/sessions')
+export const previewBudgetUploadImport = (body: unknown) => apiPost<BudgetUploadImportResult>('/api/budget/import/upload/preview', body)
+export const confirmBudgetUploadImportCandidates = (body: unknown) => apiPost<BudgetUploadImportResult>('/api/budget/import/upload/confirm-candidates', body)
 export const scanBudgetMonthlyImportDrive = (body: unknown = {}) => apiPost<Record<string, unknown>>('/api/budget/monthly-import/drive-scan', body)
 export const dryRunBudgetMonthlyImport = (body: unknown) => apiPost<BudgetMonthlyImportResult>('/api/budget/monthly-import/dry-run', body)
 export const confirmBudgetMonthlyImportCandidates = (body: unknown) => apiPost<BudgetMonthlyImportResult>('/api/budget/monthly-import/confirm-candidates', body)

Output: error TS5112: tsconfig.json is present but will not be loaded if files are specified on commandline. Use '--ignoreConfig' to skip this error.

Pre-existing lint errors — this edit didn't introduce new ones but the file is still broken.


<template>

Budget · Import

Budget Import

Datei hochladen → Dry Run → Kandidaten prüfen → Confirm. Upload erzeugt keine produktive Buchung.

      Buchungen prüfen

    <Message v-if="message" severity="success" :closable="false">{{ message }}</Message>
    <Message v-if="error" severity="error" :closable="false">{{ error }}</Message>

Datei hochladen

Unterstützt: VISA CSV, Migros CSV, Raiffeisen CSV, AKB CSV. Excel, Portfolio, OCR/Kamera und automatische Massenbuchungen sind nicht Teil dieses Workflows.

          CSV-Datei auswählen
          <input data-testid="budget-import-file" class="mt-3 block w-full text-sm" type="file" accept=".csv,text/csv" @change="onFileChange" />
          {{ selectedFileName || 'Noch keine Datei ausgewählt' }}

          Profil automatisch erkennen oder manuell wählen
          <select id="profile" v-model="profile" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
            <option value="auto">Profil automatisch erkennen</option>
            <option value="visa">VISA</option>
            <option value="migros">Migros</option>
            <option value="raiffeisen">Raiffeisen</option>
            <option value="akb">AKB</option>
          </select>

            <button class="btn-primary" :disabled="!canDryRun || loading" @click="startDryRun">Dry Run starten</button>
            <button class="btn" :disabled="!preview?.preview_id || loading" @click="createCandidates">Kandidaten erzeugen</button>

Admin/Fallback: Google Drive Scan bleibt nicht der normale User-Hauptworkflow und ist hier bewusst nicht prominent.

Dry-Run Ergebnis

Profil: {{ preview.profile || 'unbekannt' }} · Status: {{ preview.status }}

        Zu Buchungen prüfen

{{ item.label }}

          {{ item.value }}

{{ item.help }}

{{ e }}

{{ w }}

Import-Sessions

{{ s.source || 'Quelle' }} · {{ s.status }}

{{ s.profile }} · Zeilen {{ s.rows_total }} · Neu {{ s.new_candidates }} · Duplikate {{ s.duplicate_count }}

          Review öffnen

Noch keine Upload-Import-Sessions vorhanden.

</template>


No linter for .vue files


const BudgetImportReviewPage = () => import('@/pages/BudgetImportReviewPage.vue')
const BudgetImportPage = () => import('@/pages/BudgetImportPage.vue')
const BudgetMonthlyImportPage = () => import('@/pages/BudgetMonthlyImportPage.vue')

const BudgetImportReviewPage = () => import('@/pages/BudgetImportReviewPage.vue')
const BudgetMonthlyImportPage = () => import('@/pages/BudgetMonthlyImportPage.vue')


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/router/index.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/router/index.ts
@@ -17,6 +17,7 @@
 const BudgetCategoriesPage = () => import('@/pages/BudgetCategoriesPage.vue')
 const BudgetSetupPage = () => import('@/pages/BudgetSetupPage.vue')
 const BudgetImportReviewPage = () => import('@/pages/BudgetImportReviewPage.vue')
+const BudgetImportPage = () => import('@/pages/BudgetImportPage.vue')
 const BudgetMonthlyImportPage = () => import('@/pages/BudgetMonthlyImportPage.vue')
 const BudgetGroceryOptimizerPage = () => import('@/pages/BudgetGroceryOptimizerPage.vue')
 const BudgetIncomePage = () => import('@/pages/BudgetIncomePage.vue')

Output: error TS5112: tsconfig.json is present but will not be loaded if files are specified on commandline. Use '--ignoreConfig' to skip this error.

Pre-existing lint errors — this edit didn't introduce new ones but the file is still broken.


    { path: '/planning/budget/import-review', redirect: '/planning/budget/expenses/review' },
    { path: '/planning/budget/import', name: 'budget-import', component: BudgetImportPage, meta: { title: 'Budget Import' } },
    { path: '/planning/budget/category-assignment', redirect: '/planning/budget/expenses/review' },

    { path: '/planning/budget/import-review', redirect: '/planning/budget/expenses/review' },
    { path: '/planning/budget/category-assignment', redirect: '/planning/budget/expenses/review' },


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/router/index.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/router/index.ts
@@ -59,6 +59,7 @@
     { path: '/planning/budget/expenses/review', name: 'budget-expense-review', component: BudgetImportReviewPage, meta: { title: 'Buchungen prüfen' } },
     { path: '/planning/budget/transfers', name: 'budget-transfers', component: BudgetTransfersPage, meta: { title: 'Transfers' } },
     { path: '/planning/budget/import-review', redirect: '/planning/budget/expenses/review' },
+    { path: '/planning/budget/import', name: 'budget-import', component: BudgetImportPage, meta: { title: 'Budget Import' } },
     { path: '/planning/budget/category-assignment', redirect: '/planning/budget/expenses/review' },
     { path: '/planning/budget/analysis/budget-vs-actual', name: 'budget-vs-actual', component: BudgetVsActualPage, meta: { title: 'Budget vs Ist' } },
     { path: '/planning/budget/analysis/data-explorer', name: 'budget-data-explorer', component: DataExplorerPage, meta: { title: 'Daten-Explorer' } },

Output: error TS5112: tsconfig.json is present but will not be loaded if files are specified on commandline. Use '--ignoreConfig' to skip this error.

Pre-existing lint errors — this edit didn't introduce new ones but the file is still broken.


    { key: 'budget-import', to: '/planning/budget/import', label: 'Datei importieren' },
    { key: 'expenses-actual', to: '/planning/budget/expenses/actual', label: 'Effektive Ausgaben' },

    { key: 'monthly-import', to: '/planning/budget/monthly-import', label: 'Monatsimport' },
    { key: 'expenses-actual', to: '/planning/budget/expenses/actual', label: 'Effektive Ausgaben' },


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/navigation/userNav.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/navigation/userNav.ts
@@ -23,7 +23,7 @@
     { key: 'budget-plans', to: '/planning/budget/plans', label: 'Budgetpläne' },
   ] },
   { title: 'Transaktionen', items: [
-    { key: 'monthly-import', to: '/planning/budget/monthly-import', label: 'Monatsimport' },
+    { key: 'budget-import', to: '/planning/budget/import', label: 'Datei importieren' },
     { key: 'expenses-actual', to: '/planning/budget/expenses/actual', label: 'Effektive Ausgaben' },
     { key: 'income-actual', to: '/planning/budget/income/actual', label: 'Effektive Einnahmen' },
     { key: 'review', to: '/planning/budget/expenses/review', label: 'Buchungen prüfen' },

Output: error TS5112: tsconfig.json is present but will not be loaded if files are specified on commandline. Use '--ignoreConfig' to skip this error.

Pre-existing lint errors — this edit didn't introduce new ones but the file is still broken.


  { key: 'review', to: '/planning/budget/expenses/review', label: 'Buchungen prüfen' },
  { key: 'budget-import', to: '/planning/budget/import', label: 'Datei importieren' },
  { key: 'transfers', to: '/planning/budget/transfers', label: 'Transfers' },

  { key: 'review', to: '/planning/budget/expenses/review', label: 'Buchungen prüfen' },
  { key: 'transfers', to: '/planning/budget/transfers', label: 'Transfers' },


Found 2 matches for old_string. Provide more context to make it unique, or use replace_all=True.