Explanation:
**Considering database schema**

I’m thinking about the database schema and whether I really need the import_session column. It’s possible that it could be unnecessary, so I have to weigh that decision carefully. I want to ensure the schema is efficient and serves the project well. I guess I'll need to explore what the implications of removing that column might be for the overall functionality. Let’s see where this takes me!

from pathlib import Path
root=Path('/home/agent/.hermes/repos/FinanceManager')
for p in root.rglob('*.py'):
    if 'venv' in p.parts: continue
    txt=p.read_text(errors='ignore')
    if 'budget_transaction_candidates' in txt and ('CREATE TABLE' in txt or 'ALTER TABLE' in txt):
        print(p)
        for i,l in enumerate(txt.splitlines(),1):
            if 'budget_transaction_candidates' in l or 'import_session' in l:
                print(i,l[:180])


Output:
/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/storage/migrations.py
713         CREATE TABLE IF NOT EXISTS budget_transaction_candidates (
733         CREATE INDEX IF NOT EXISTS idx_budget_transaction_candidates_status ON budget_transaction_candidates(status);
734         CREATE INDEX IF NOT EXISTS idx_budget_transaction_candidates_source ON budget_transaction_candidates(source_file_label);
735         CREATE INDEX IF NOT EXISTS idx_budget_transaction_candidates_category ON budget_transaction_candidates(proposed_category_id);
741     # Phase 1.4 created budget_transaction_candidates with a narrow CHECK on status.
744     row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='budget_transaction_candidates'").fetchone()
746         conn.execute("ALTER TABLE budget_transaction_candidates RENAME TO budget_transaction_candidates__phase14")
749             CREATE TABLE budget_transaction_candidates (
778             INSERT INTO budget_transaction_candidates(
788             FROM budget_transaction_candidates__phase14;
789             DROP TABLE budget_transaction_candidates__phase14;
792     _add_missing_columns(conn, "budget_transaction_candidates", {
806         CREATE INDEX IF NOT EXISTS idx_budget_transaction_candidates_status ON budget_transaction_candidates(status);
807         CREATE INDEX IF NOT EXISTS idx_budget_transaction_candidates_source ON budget_transaction_candidates(source_file_label);
808         CREATE INDEX IF NOT EXISTS idx_budget_transaction_candidates_category ON budget_transaction_candidates(proposed_category_id);
809         CREATE INDEX IF NOT EXISTS idx_budget_transaction_candidates_classification ON budget_transaction_candidates(classification);
810         CREATE INDEX IF NOT EXISTS idx_budget_transaction_candidates_receipt ON budget_transaction_candidates(receipt_key);
814             transaction_candidate_id TEXT NOT NULL REFERENCES budget_transaction_candidates(transaction_candidate_id),
831             transaction_candidate_id TEXT NOT NULL REFERENCES budget_transaction_candidates(transaction_candidate_id),
860     _add_missing_columns(conn, "budget_transaction_candidates", {
911     cand_row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='budget_transaction_candidates'").fetchone()
914         conn.execute("ALTER TABLE budget_transaction_candidates RENAME TO budget_transaction_candidates__phase18")
917             CREATE TABLE budget_transaction_candidates (
950             INSERT INTO budget_transaction_candidates(
966             FROM budget_transaction_candidates__phase18;
967             DROP TABLE budget_transaction_candidates__phase18;
1039         CREATE INDEX IF NOT EXISTS idx_budget_transaction_candidates_confirmed_tx ON budget_transaction_candidates(confirmed_transaction_id);
1074     _add_missing_columns(conn, "budget_transaction_candidates", {
1216     _add_missing_columns(conn, "budget_transaction_candidates", {
1217         "import_session_id": "TEXT",
1221         CREATE TABLE IF NOT EXISTS budget_import_sessions (
1222             import_session_id TEXT PRIMARY KEY,
1242         CREATE INDEX IF NOT EXISTS idx_budget_import_sessions_created ON budget_import_sessions(created_at);
1243         CREATE INDEX IF NOT EXISTS idx_budget_import_sessions_source ON budget_import_sessions(source, profile);
1247             example_candidate_id TEXT REFERENCES budget_transaction_candidates(transaction_candidate_id),


  1210|            CREATE INDEX IF NOT EXISTS idx_budget_recurring_merchant ON budget_recurring_payments(merchant_id, name);
  1211|            """
  1212|        )
  1213|
  1214|
  1215|def _create_budget_monthly_import_rule_learning_v1_tables(conn: Connection) -> None:
  1216|    _add_missing_columns(conn, "budget_transaction_candidates", {
  1217|        "import_session_id": "TEXT",
  1218|    })
  1219|    conn.executescript(
  1220|        """
  1221|        CREATE TABLE IF NOT EXISTS budget_import_sessions (
  1222|            import_session_id TEXT PRIMARY KEY,
  1223|            source TEXT,
  1224|            file_id TEXT,
  1225|            file_name TEXT,
  1226|            file_modified_time TEXT,
  1227|            file_period_start TEXT,
  1228|            file_period_end TEXT,
  1229|            profile TEXT,
  1230|            rows_total INTEGER NOT NULL DEFAULT 0,
  1231|            new_candidates INTEGER NOT NULL DEFAULT 0,
  1232|            already_known INTEGER NOT NULL DEFAULT 0,
  1233|            duplicate_count INTEGER NOT NULL DEFAULT 0,
  1234|            ignored_count INTEGER NOT NULL DEFAULT 0,
  1235|            covered_by_source_count INTEGER NOT NULL DEFAULT 0,
  1236|            error_count INTEGER NOT NULL DEFAULT 0,
  1237|            status TEXT NOT NULL,
  1238|            review_url TEXT,
  1239|            created_at TEXT NOT NULL,
  1240|            updated_at TEXT NOT NULL
  1241|        );
  1242|        CREATE INDEX IF NOT EXISTS idx_budget_import_sessions_created ON budget_import_sessions(created_at);
  1243|        CREATE INDEX IF NOT EXISTS idx_budget_import_sessions_source ON budget_import_sessions(source, profile);
  1244|
  1245|        CREATE TABLE IF NOT EXISTS budget_rule_suggestions (
  1246|            suggestion_id TEXT PRIMARY KEY,
  1247|            example_candidate_id TEXT REFERENCES budget_transaction_candidates(transaction_candidate_id),
  1248|            merchant_pattern TEXT NOT NULL,
  1249|            description_pattern TEXT,
  1250|            match_type TEXT NOT NULL DEFAULT 'contains',
  1251|            source_scope TEXT NOT NULL DEFAULT 'all',
  1252|            category_id TEXT REFERENCES budget_categories(category_id),
  1253|            category_name TEXT,
  1254|            recurring_type TEXT,
  1255|            amount_min_text TEXT,
  1256|            amount_max_text TEXT,
  1257|            confidence TEXT NOT NULL DEFAULT '0.70',
  1258|            affected_open_candidate_count INTEGER NOT NULL DEFAULT 0,
  1259|            status TEXT NOT NULL DEFAULT 'suggested',
  1260|            notes TEXT,
  1261|            created_at TEXT NOT NULL,
  1262|            updated_at TEXT NOT NULL
  1263|        );
  1264|        CREATE INDEX IF NOT EXISTS idx_budget_rule_suggestions_status ON budget_rule_suggestions(status, source_scope);
  1265|        """
  1266|    )
  1267|
  1268|
  1269|def _create_grocery_optimizer_v1_tables(conn: Connection) -> None:
  1270|    conn.executescript(
  1271|        """
  1272|        CREATE TABLE IF NOT EXISTS grocery_product_items (
  1273|            product_item_id TEXT PRIMARY KEY,
  1274|            source_type TEXT NOT NULL DEFAULT 'migros_receipt',
  1275|            receipt_id TEXT NOT NULL,
  1276|            receipt_key TEXT,
  1277|            source_line_id TEXT,
  1278|            purchase_date TEXT,
  1279|            store_name TEXT,
  1280|            raw_product_name TEXT NOT NULL,
  1281|            normalized_product_name TEXT,
  1282|            normalization_confidence TEXT NOT NULL DEFAULT '0',
  1283|            brand_hint TEXT,
  1284|            quantity_hint TEXT,
  1285|            quantity_text TEXT,
  1286|            unit TEXT,
  1287|            unit_price_text TEXT,
  1288|            total_price_text TEXT NOT NULL,
  1289|            currency TEXT NOT NULL DEFAULT 'CHF',
  1290|

Use offset=1290 to continue reading (showing 1210-1289 of 1465 lines)


     1|from __future__ import annotations
     2|
     3|import json
     4|import subprocess
     5|from collections import Counter, defaultdict
     6|from datetime import datetime
     7|from pathlib import Path
     8|from sqlite3 import Connection
     9|from typing import Any
    10|from urllib.parse import urlencode
    11|
    12|from jarvis_finance.audit.log import record_audit_event
    13|from jarvis_finance.services.budget_common import new_id, now, row_to_dict
    14|from jarvis_finance.services.budget_csv_imports import detect_import_profile, import_budget_csv_text, parse_budget_csv_text
    15|from jarvis_finance.services.budget_imports import _normalise, _normalized_merchant_name, create_budget_rule
    16|
    17|OPEN_STATUSES = {"pending", "auto_categorized", "ready_for_confirm", "needs_review", "duplicate_candidate", "transfer_candidate"}
    18|REVIEW_HIDDEN_STATUSES = {"confirmed", "ignored", "covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference"}
    19|
    20|
    21|def _safe_file_name(name: str) -> str:
    22|    return Path(str(name or "budget.csv")).name[:180]
    23|
    24|
    25|def _source_from_profile(profile: str | None, name: str = "") -> str:
    26|    text = f"{profile or ''} {name}".lower()
    27|    if "visa" in text:
    28|        return "VISA"
    29|    if "migros" in text or "cumulus" in text:
    30|        return "Migros"
    31|    if "raiffeisen" in text:
    32|        return "Raiffeisen"
    33|    if "akb" in text or "kantonal" in text:
    34|        return "AKB"
    35|    return "unknown"
    36|
    37|
    38|def _file_text(file: dict[str, Any]) -> str:
    39|    if "text" in file:
    40|        return str(file.get("text") or "")
    41|    path = file.get("path") or file.get("local_path")
    42|    if path:
    43|        return Path(path).read_text(encoding=file.get("encoding") or "utf-8-sig", errors="replace")
    44|    return ""
    45|
    46|
    47|def scan_budget_drive_files(files: list[dict[str, Any]] | None = None) -> dict[str, Any]:
    48|    """Classify already discovered Drive file metadata/text. No writes."""
    49|    scanned = []
    50|    for f in files or []:
    51|        name = _safe_file_name(str(f.get("name") or f.get("filename") or "budget.csv"))
    52|        profile = None
    53|        rows_total = 0
    54|        errors: list[str] = []
    55|        text = _file_text(f)
    56|        try:
    57|            if text.strip():
    58|                parsed = parse_budget_csv_text(text)
    59|                profile = parsed["profile"]
    60|                rows_total = len(parsed["rows"])
    61|            else:
    62|                lowered = name.lower()
    63|                if "visa" in lowered:
    64|                    profile = "visa_credit_card"
    65|                elif "migros" in lowered or "cumulus" in lowered:
    66|                    profile = "migros_receipts"
    67|                elif "raiffeisen" in lowered:
    68|                    profile = "raiffeisen_bank"
    69|                elif "akb" in lowered or "kantonal" in lowered:
    70|                    profile = "akb_bank"
    71|        except Exception as exc:  # malformed file remains visible, not fatal
    72|            errors.append(type(exc).__name__)
    73|        scanned.append({
    74|            "file_id": str(f.get("id") or f.get("file_id") or ""),
    75|            "name": name,
    76|            "modifiedTime": f.get("modifiedTime") or f.get("modified_time"),
    77|            "profile": profile,
    78|            "source": _source_from_profile(profile, name),
    79|            "rows_total": rows_total,
    80|            "status": "recognized" if profile else "unsupported",
    81|            "errors": errors,
    82|        })
    83|    return {"purpose": "budget_monthly_import_drive_scan", "folder": "Finanzen/03 Budget", "files": scanned, "recognized_count": sum(1 for f in scanned if f["profile"])}
    84|
    85|
    86|def scan_google_drive_budget_folder() -> dict[str, Any]:
    87|    """Read-only metadata scan via gog if available. It never downloads/imports contents."""
    88|    cmd = ["gog", "-a", "friday.uplink@gmail.com", "drive", "search", "Finanzen/03 Budget"]
    89|    try:
    90|        proc = subprocess.run(cmd, text=True, capture_output=True, timeout=45, check=False)
    91|        return {"purpose": "budget_monthly_import_drive_scan", "folder": "Finanzen/03 Budget", "status": "available" if proc.returncode == 0 else "unavailable", "raw_count_hint": len(proc.stdout.splitlines()), "files": []}
    92|    except Exception as exc:
    93|        return {"purpose": "budget_monthly_import_drive_scan", "folder": "Finanzen/03 Budget", "status": "unavailable", "error": type(exc).__name__, "files": []}
    94|
    95|
    96|def _review_url(source: str | None = None, session_id: str | None = None, status: str | None = None) -> str:
    97|    params = {"source": source or "", "import_session": session_id or "", "status": status or ""}
    98|    return "/planning/budget/expenses/review?" + urlencode({k: v for k, v in params.items() if v})
    99|
   100|
   101|def _insert_session(conn: Connection, *, file: dict[str, Any], profile: str | None, rows_total: int, result: dict[str, Any], status: str, review_url: str) -> str:
   102|    sid = new_id("bimport")
   103|    ts = now()
   104|    source = _source_from_profile(profile, str(file.get("name") or ""))
   105|    conn.execute(
   106|        """
   107|        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)
   108|        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   109|        """,
   110|        (
   111|            sid, source, str(file.get("id") or file.get("file_id") or ""), _safe_file_name(str(file.get("name") or "budget.csv")), file.get("modifiedTime") or file.get("modified_time"),
   112|            result.get("period_start"), result.get("period_end"), profile, int(rows_total or 0), int(result.get("candidate_count") or result.get("would_create_candidate_count") or result.get("receipt_candidate_count") or 0), int(result.get("already_processed_count") or 0), int(result.get("possible_duplicate_count") or result.get("duplicate_count") or 0), int(result.get("ignored_count") or 0), int(result.get("covered_by_migros_count") or result.get("covered_by_source_count") or 0), int(result.get("error_count") or 0), status, review_url, ts, ts,
   113|        ),
   114|    )
   115|    return sid
   116|
   117|
   118|def _sum_totals(items: list[dict[str, Any]]) -> dict[str, int]:
   119|    keys = ["would_create_candidate_count", "candidate_count", "already_processed_count", "possible_duplicate_count", "ignored_count", "covered_by_source_count", "receipt_candidate_count", "line_item_count", "income_candidate_count", "transfer_candidate_count"]
   120|    return {k: sum(int(i.get(k) or 0) for i in items) for k in keys}
   121|
   122|
   123|def preview_drive_monthly_import(conn: Connection, files: list[dict[str, Any]], *, dry_run: bool = True, default_category_id: str | None = None, income_category_id: str | None = None) -> dict[str, Any]:
   124|    file_results: list[dict[str, Any]] = []
   125|    scan = scan_budget_drive_files(files)
   126|    session_ids: list[str] = []
   127|    for scanned, f in zip(scan["files"], files):
   128|        profile = scanned.get("profile")
   129|        if not profile:
   130|            result = {"profile": None, "error_count": 1, "ignored_count": 1}
   131|            status = "failed"
   132|        else:
   133|            result = import_budget_csv_text(conn, str(profile), _file_text(f), source_file_label=_safe_file_name(str(f.get("name") or "budget.csv")), dry_run=dry_run, default_category_id=default_category_id, income_category_id=income_category_id)
   134|            status = "dry_run" if dry_run else "candidates_created"
   135|        review = _review_url(scanned.get("source"), None, "ready_for_confirm")
   136|        sid = _insert_session(conn, file=f, profile=profile, rows_total=int(scanned.get("rows_total") or result.get("parsed_rows") or 0), result=result, status=status, review_url=review)
   137|        session_ids.append(sid)
   138|        if not dry_run:
   139|            conn.execute("UPDATE budget_transaction_candidates SET import_session_id=? WHERE source_file_label=? AND import_session_id IS NULL", (sid, _safe_file_name(str(f.get("name") or "budget.csv"))))
   140|        result = dict(result) | {"import_session_id": sid, "source": scanned.get("source"), "file_name": scanned.get("name"), "review_url": _review_url(scanned.get("source"), sid, "ready_for_confirm")}
   141|        file_results.append(result)
   142|    conn.commit()
   143|    return {"purpose": "budget_monthly_import_workflow_v1", "status": "dry_run" if dry_run else "candidates_created", "files_total": len(files), "session_ids": session_ids, "files": file_results, "totals": _sum_totals(file_results), "review_url": _review_url(session_id=session_ids[-1] if session_ids else None, status="ready_for_confirm")}
   144|
   145|
   146|def run_monthly_import_dry_run(conn: Connection, files: list[dict[str, Any]], *, default_category_id: str | None = None, income_category_id: str | None = None) -> dict[str, Any]:
   147|    return preview_drive_monthly_import(conn, files, dry_run=True, default_category_id=default_category_id, income_category_id=income_category_id)
   148|
   149|
   150|def get_import_history(conn: Connection, *, limit: int = 30) -> list[dict[str, Any]]:
   151|    rows = conn.execute("SELECT * FROM budget_import_sessions ORDER BY created_at DESC, import_session_id DESC LIMIT ?", (limit,)).fetchall()
   152|    return [row_to_dict(r) for r in rows]
   153|
   154|
   155|def get_monthly_import_dashboard(conn: Connection) -> dict[str, Any]:
   156|    sessions = get_import_history(conn, limit=100)
   157|    latest_by_source: dict[str, dict[str, Any]] = {}
   158|    for s in sessions:
   159|        latest_by_source.setdefault(str(s.get("source") or "unknown"), s)
   160|    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()
   161|    by_source: dict[str, int] = defaultdict(int)
   162|    counts = Counter()
   163|    for r in open_rows:
   164|        by_source[str(r["source_type"] or "unknown")] += int(r["count"] or 0)
   165|        if r["status"]:
   166|            counts[str(r["status"])] += int(r["count"] or 0)
   167|        if r["classification"]:
   168|            counts[str(r["classification"])] += int(r["count"] or 0)
   169|    suggestions = conn.execute("SELECT COUNT(*) FROM budget_rule_suggestions WHERE status IN ('suggested','later')").fetchone()[0]
   170|    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")}
   171|
   172|
   173|def _candidate(conn: Connection, candidate_id: str):
   174|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   175|    if not row:
   176|        raise ValueError("candidate not found")
   177|    return row
   178|
   179|
   180|def _affected_count(conn: Connection, merchant_pattern: str, source_scope: str, exclude_id: str | None = None) -> int:
   181|    source_clause = "" if source_scope in {"", "all"} else " AND lower(source_type) LIKE ?"
   182|    params: list[Any] = [f"%{_normalise(merchant_pattern)}%", f"%{_normalise(merchant_pattern)}%"]
   183|    if source_clause:
   184|        params.append(f"%{source_scope.lower()}%")
   185|    if exclude_id:
   186|        source_clause += " AND transaction_candidate_id<>?"; params.append(exclude_id)
   187|    sql = "SELECT COUNT(*) FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded') AND (lower(COALESCE(merchant,'')) LIKE ? OR lower(COALESCE(description,'')) LIKE ?)" + source_clause
   188|    return int(conn.execute(sql, params).fetchone()[0] or 0)
   189|
   190|
   191|def create_rule_suggestion_from_candidate_change(conn: Connection, candidate_id: str, *, category_id: str, source_scope: str = "all", recurring_type: str | None = None) -> dict[str, Any]:
   192|    row = _candidate(conn, candidate_id)
   193|    cat = conn.execute("SELECT name FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
   194|    merchant = str(row["merchant"] or row["description"] or "").strip()
   195|    pattern = _normalized_merchant_name(merchant).upper() or merchant.upper()
   196|    affected = _affected_count(conn, pattern, source_scope, exclude_id=candidate_id)
   197|    sid = new_id("rulesug")
   198|    ts = now()
   199|    conn.execute("""
   200|        INSERT INTO budget_rule_suggestions(suggestion_id,example_candidate_id,merchant_pattern,description_pattern,match_type,source_scope,category_id,category_name,recurring_type,amount_min_text,amount_max_text,confidence,affected_open_candidate_count,status,notes,created_at,updated_at)
   201|        VALUES (?, ?, ?, ?, 'contains', ?, ?, ?, ?, NULL, NULL, ?, ?, 'suggested', ?, ?, ?)
   202|    """, (sid, candidate_id, pattern, str(row["description"] or "")[:180], source_scope or "all", category_id, cat["name"] if cat else None, recurring_type, "0.84" if affected else "0.70", affected, json.dumps({"trigger": "manual_category_change"}), ts, ts))
   203|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_suggestion_created", entity_type="budget_rule_suggestion", entity_id=sid, new_values={"merchant_pattern": pattern, "category_id": category_id, "source_scope": source_scope}, created_by="user")
   204|    conn.commit()
   205|    return row_to_dict(conn.execute("SELECT * FROM budget_rule_suggestions WHERE suggestion_id=?", (sid,)).fetchone()) | {"audit_id": audit_id}
   206|
   207|
   208|def list_rule_suggestions(conn: Connection, *, status: str | None = None) -> list[dict[str, Any]]:
   209|    if status:
   210|        rows = conn.execute("SELECT * FROM budget_rule_suggestions WHERE status=? ORDER BY created_at DESC", (status,)).fetchall()
   211|    else:
   212|        rows = conn.execute("SELECT * FROM budget_rule_suggestions ORDER BY created_at DESC").fetchall()
   213|    return [row_to_dict(r) for r in rows]
   214|
   215|
   216|def apply_rule_suggestion(conn: Connection, suggestion_id: str, *, apply_to_open: bool = False) -> dict[str, Any]:
   217|    row = conn.execute("SELECT * FROM budget_rule_suggestions WHERE suggestion_id=?", (suggestion_id,)).fetchone()
   218|    if not row:
   219|        raise ValueError("rule suggestion not found")
   220|    rule = create_budget_rule(conn, {"merchant_contains": row["merchant_pattern"], "source_type": None if row["source_scope"] in {"", "all"} else row["source_scope"], "category_id": row["category_id"], "target_status": "review", "notes": "created_from_rule_suggestion"})
   221|    updated = 0
   222|    if apply_to_open:
   223|        params: list[Any] = [f"%{_normalise(row['merchant_pattern'])}%", f"%{_normalise(row['merchant_pattern'])}%"]
   224|        source_clause = ""
   225|        if row["source_scope"] not in {"", "all", None}:
   226|            source_clause = " AND lower(source_type) LIKE ?"; params.append(f"%{str(row['source_scope']).lower()}%")
   227|        params.extend([row["category_id"], row["category_name"], rule["rule_id"], row["merchant_pattern"], now()])
   228|        sql = """
   229|            UPDATE budget_transaction_candidates
   230|            SET proposed_category_id=?, proposed_category_name=?, rule_id=?, rule_name=?, updated_at=?
   231|            WHERE status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded')
   232|              AND (lower(COALESCE(merchant,'')) LIKE ? OR lower(COALESCE(description,'')) LIKE ?)
   233|        """
   234|        # SQLite parameter order: SET params first, WHERE params last
   235|        set_params = [row["category_id"], row["category_name"], rule["rule_id"], row["merchant_pattern"], now()]
   236|        where_params = [f"%{_normalise(row['merchant_pattern'])}%", f"%{_normalise(row['merchant_pattern'])}%"]
   237|        if source_clause:
   238|            where_params.append(f"%{str(row['source_scope']).lower()}%")
   239|        cur = conn.execute(sql + source_clause, set_params + where_params)
   240|        updated = int(cur.rowcount or 0)
   241|    conn.execute("UPDATE budget_rule_suggestions SET status='accepted', updated_at=? WHERE suggestion_id=?", (now(), suggestion_id))
   242|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_suggestion_accepted", entity_type="budget_rule_suggestion", entity_id=suggestion_id, new_values={"apply_to_open": apply_to_open, "updated_candidate_count": updated}, created_by="user")
   243|    conn.commit()
   244|    return {"status": "accepted", "suggestion_id": suggestion_id, "rule_id": rule["rule_id"], "updated_candidate_count": updated, "audit_id": audit_id}
   245|
   246|
   247|def reject_rule_suggestion(conn: Connection, suggestion_id: str, *, later: bool = False) -> dict[str, Any]:
   248|    status = "later" if later else "rejected"
   249|    conn.execute("UPDATE budget_rule_suggestions SET status=?, updated_at=? WHERE suggestion_id=?", (status, now(), suggestion_id))
   250|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_suggestion_rejected" if not later else "budget_rule_suggestion_later", entity_type="budget_rule_suggestion", entity_id=suggestion_id, new_values={"status": status}, created_by="user")
   251|    conn.commit()
   252|    return {"status": status, "suggestion_id": suggestion_id, "audit_id": audit_id}
   253|


Explanation:
**Preparing for tests**

I need to get ready for testing. I should use schema test helpers to make sure everything functions correctly. It seems like listing out all the tests I want to run will help me stay organized and on track. By breaking it down into smaller tasks, I can avoid feeling overwhelmed. I’ll make sure to cover all the necessary areas in these tests as I move forward with the process!


     1|from __future__ import annotations
     2|
     3|from jarvis_finance.services.budget_accounts import confirm_create_budget_account
     4|from jarvis_finance.services.budget_categories import confirm_create_category
     5|from jarvis_finance.services.budget_imports import list_transaction_candidates, update_transaction_candidate_category
     6|from jarvis_finance.services.budget_monthly_import import (
     7|    apply_rule_suggestion,
     8|    create_rule_suggestion_from_candidate_change,
     9|    get_import_history,
    10|    get_monthly_import_dashboard,
    11|    preview_drive_monthly_import,
    12|    run_monthly_import_dry_run,
    13|    scan_budget_drive_files,
    14|)
    15|from jarvis_finance.storage.database import connect_memory
    16|from jarvis_finance.storage.migrations import apply_migrations, get_schema_version
    17|
    18|
    19|def db():
    20|    conn = connect_memory()
    21|    apply_migrations(conn)
    22|    return conn
    23|
    24|
    25|def setup_base(conn):
    26|    account = confirm_create_budget_account(conn, {"name": "Budget Konto", "account_type": "cash", "currency": "CHF"})["entity_id"]
    27|    food = confirm_create_category(conn, {"name": "Essen & Haushalt", "category_type": "expense"})["entity_id"]
    28|    media = confirm_create_category(conn, {"name": "Elektronische Medien", "category_type": "expense"})["entity_id"]
    29|    income = confirm_create_category(conn, {"name": "Lohn Marcel", "category_type": "income"})["entity_id"]
    30|    return account, food, media, income
    31|
    32|
    33|def test_monthly_import_drive_scan_detects_profiles_and_writes_dry_run_session() -> None:
    34|    conn = db(); _account, food, _media, income = setup_base(conn)
    35|    files = [
    36|        {"id": "drv_visa", "name": "VISA_Mai.csv", "modifiedTime": "2026-05-18T10:00:00Z", "text": "TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nT1,C1,2026-05-01,-10.00,CHF,Netflix,Abo\n"},
    37|        {"id": "drv_migros", "name": "Migros_Mai.csv", "modifiedTime": "2026-05-18T10:01:00Z", "text": "Datum;Zeit;Filiale;Kassennummer;Transaktionsnummer;Artikel;Menge;Aktion;Umsatz\n02.05.2026;10:00;Migros Test;1;99;Artikel;1;;1.00\n"},
    38|        {"id": "drv_akb", "name": "AKB_Mai.csv", "modifiedTime": "2026-05-18T10:02:00Z", "text": "Buchung;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF\n03.05.2026;03.05.2026;Lohn Marcel;;20.00;0\n"},
    39|    ]
    40|
    41|    scanned = scan_budget_drive_files(files)
    42|    dry = run_monthly_import_dry_run(conn, files, default_category_id=food, income_category_id=income)
    43|    history = get_import_history(conn)
    44|
    45|    assert [f["profile"] for f in scanned["files"]] == ["visa_credit_card", "migros_receipts", "akb_bank"]
    46|    assert dry["status"] == "dry_run"
    47|    assert dry["files_total"] == 3
    48|    assert dry["totals"]["would_create_candidate_count"] >= 3
    49|    assert history[0]["status"] == "dry_run"
    50|    assert sum(int(h["rows_total"] or 0) for h in history) >= 3
    51|    assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates").fetchone()[0] == 0
    52|
    53|
    54|def test_monthly_import_confirm_creates_candidates_and_second_run_is_idempotent() -> None:
    55|    conn = db(); _account, food, _media, income = setup_base(conn)
    56|    files = [{"id": "drv_visa", "name": "VISA_Mai.csv", "text": "TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nT1,C1,2026-05-01,-10.00,CHF,Netflix,Abo\n"}]
    57|
    58|    first = preview_drive_monthly_import(conn, files, dry_run=False, default_category_id=food, income_category_id=income)
    59|    second = preview_drive_monthly_import(conn, files, dry_run=False, default_category_id=food, income_category_id=income)
    60|    rows = list_transaction_candidates(conn, include_reference=True)
    61|
    62|    assert first["status"] == "candidates_created"
    63|    assert first["totals"]["candidate_count"] == 1
    64|    assert second["totals"]["candidate_count"] == 0
    65|    assert second["totals"]["already_processed_count"] == 1
    66|    assert len(rows) == 1
    67|    assert rows[0]["status"] in {"pending", "auto_categorized", "needs_review"}
    68|
    69|
    70|def test_monthly_import_classification_dashboard_and_review_link_filters() -> None:
    71|    conn = db(); _account, food, _media, income = setup_base(conn)
    72|    files = [
    73|        {"id": "drv_migros", "name": "Migros.csv", "text": "Datum;Zeit;Filiale;Kassennummer;Transaktionsnummer;Artikel;Menge;Aktion;Umsatz\n02.05.2026;10:00;Migros Test;1;99;Artikel;1;;1.00\n"},
    74|        {"id": "drv_bank", "name": "Raiffeisen.csv", "text": "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\nCH1;2026-05-03;True Wealth Einzahlung;-20.00;0;2026-05-03\nCH1;2026-05-04;VISA Kartenabrechnung;-30.00;0;2026-05-04\nCH1;2026-05-05;Lohn Marcel;40.00;0;2026-05-05\n"},
    75|    ]
    76|    result = preview_drive_monthly_import(conn, files, dry_run=False, default_category_id=food, income_category_id=income)
    77|    dashboard = get_monthly_import_dashboard(conn)
    78|    rows = list_transaction_candidates(conn, include_reference=True)
    79|    classifications = {r["classification"] for r in rows}
    80|
    81|    assert result["review_url"].startswith("/planning/budget/expenses/review?")
    82|    assert "migros_receipt_food_household" in classifications
    83|    assert "investment_transfer" in classifications
    84|    assert "credit_card_payment" in classifications
    85|    assert "income_candidate" in classifications
    86|    assert dashboard["open_candidates_by_source"]
    87|    assert dashboard["counts"]["income_candidate"] >= 1
    88|    assert dashboard["counts"]["transfer_candidate"] >= 2
    89|
    90|
    91|def test_rule_learning_suggests_rule_from_manual_category_change_and_applies_without_booking() -> None:
    92|    conn = db(); _account, food, media, _income = setup_base(conn)
    93|    files = [{"id": "drv_visa", "name": "VISA.csv", "text": "TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nT1,C1,2026-05-01,-10.00,CHF,NETFLIX,Abo\nT2,C1,2026-05-02,-10.00,CHF,NETFLIX,Abo 2\n"}]
    94|    preview_drive_monthly_import(conn, files, dry_run=False, default_category_id=food)
    95|    first = list_transaction_candidates(conn, include_reference=True)[0]
    96|    update_transaction_candidate_category(conn, first["transaction_candidate_id"], media, "manual learning test")
    97|
    98|    suggestion = create_rule_suggestion_from_candidate_change(conn, first["transaction_candidate_id"], category_id=media, source_scope="VISA")
    99|    applied = apply_rule_suggestion(conn, suggestion["suggestion_id"], apply_to_open=True)
   100|    rows = list_transaction_candidates(conn, include_reference=True)
   101|
   102|    assert suggestion["match_type"] == "contains"
   103|    assert suggestion["affected_open_candidate_count"] >= 1
   104|    assert applied["status"] == "accepted"
   105|    assert applied["updated_candidate_count"] >= 1
   106|    assert conn.execute("SELECT COUNT(*) FROM budget_transactions").fetchone()[0] == 0
   107|    assert all(r["proposed_category_id"] == media for r in rows if "NETFLIX" in (r["merchant"] or r["description"]))
   108|
   109|
   110|def test_schema_version_32_grocery_optimizer_tables_exist() -> None:
   111|    conn = db()
   112|    assert get_schema_version(conn) == 36
   113|    for table in ["budget_import_sessions", "budget_rule_suggestions"]:
   114|        assert conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
   115|


Explanation:
**Considering upload implementation**

I’m pondering whether to implement upload endpoints using JSON base64. It seems that for FastAPI’s UploadFile to work, I might need to have python-multipart installed, so I better check that. The frontend could potentially utilize FormData for uploads. However, I’m realizing that creating tests using TestClient could take a significant amount of time. Perhaps I should inspect the frontend routes and menu structure before proceeding further.


     1|import { createRouter, createWebHistory } from 'vue-router'
     2|
     3|const CommandCenter = () => import('@/pages/CommandCenter.vue')
     4|const PortfolioPage = () => import('@/pages/PortfolioPage.vue')
     5|const CryptoPage = () => import('@/pages/CryptoPage.vue')
     6|const EquityPage = () => import('@/pages/EquityPage.vue')
     7|const WalletsPage = () => import('@/pages/WalletsPage.vue')
     8|const ReportsPage = () => import('@/pages/ReportsPage.vue')
     9|const PositionAddPage = () => import('@/pages/PositionAddPage.vue')
    10|const RoadmapPage = () => import('@/pages/RoadmapPage.vue')
    11|const BudgetOverviewPage = () => import('@/pages/BudgetOverviewPage.vue')
    12|const BudgetStatusPage = () => import('@/pages/BudgetStatusPage.vue')
    13|const BudgetPlanningPage = () => import('@/pages/BudgetPlanningPage.vue')
    14|const BudgetVsActualPage = () => import('@/pages/BudgetVsActualPage.vue')
    15|const BudgetTransactionsPage = () => import('@/pages/BudgetTransactionsPage.vue')
    16|const BudgetAccountsPage = () => import('@/pages/BudgetAccountsPage.vue')
    17|const BudgetCategoriesPage = () => import('@/pages/BudgetCategoriesPage.vue')
    18|const BudgetSetupPage = () => import('@/pages/BudgetSetupPage.vue')
    19|const BudgetImportReviewPage = () => import('@/pages/BudgetImportReviewPage.vue')
    20|const BudgetMonthlyImportPage = () => import('@/pages/BudgetMonthlyImportPage.vue')
    21|const BudgetGroceryOptimizerPage = () => import('@/pages/BudgetGroceryOptimizerPage.vue')
    22|const BudgetIncomePage = () => import('@/pages/BudgetIncomePage.vue')
    23|const BudgetRulesPage = () => import('@/pages/BudgetRulesPage.vue')
    24|const BudgetRecurringPage = () => import('@/pages/BudgetRecurringPage.vue')
    25|const BudgetTransfersPage = () => import('@/pages/BudgetTransfersPage.vue')
    26|const DataExplorerPage = () => import('@/pages/DataExplorerPage.vue')
    27|const CategoryAnalysisPage = () => import('@/pages/CategoryAnalysisPage.vue')
    28|const MonthlyComparisonPage = () => import('@/pages/MonthlyComparisonPage.vue')
    29|
    30|const later = (title: string, description: string) => ({ component: RoadmapPage, props: { title, description }, meta: { title } })
    31|const router = createRouter({
    32|  history: createWebHistory(),
    33|  routes: [
    34|    { path: '/', name: 'command-center', component: CommandCenter, meta: { title: 'Command Center' } },
    35|    { path: '/portfolio', name: 'portfolio', component: PortfolioPage, meta: { title: 'Portfolio' } },
    36|    { path: '/crypto', name: 'crypto', component: CryptoPage, meta: { title: 'Crypto' } },
    37|    { path: '/equity', name: 'equity', component: EquityPage, meta: { title: 'Aktien & ETFs' } },
    38|    { path: '/wallets', name: 'wallets', component: WalletsPage, meta: { title: 'Wallets' } },
    39|    { path: '/positions/add', name: 'position-add', component: PositionAddPage, meta: { title: 'Position hinzufügen' } },
    40|    { path: '/reports', name: 'reports', component: ReportsPage, meta: { title: 'Reports' } },
    41|    { path: '/planning/budget', name: 'budget-overview', component: BudgetOverviewPage, meta: { title: 'Budget Übersicht' } },
    42|    { path: '/planning/budget/status', name: 'budget-status', component: BudgetStatusPage, meta: { title: 'Budgetstatus nach Kategorie' } },
    43|    { path: '/planning/budget/planning', name: 'budget-planning', component: BudgetPlanningPage, meta: { title: 'Budgetplanung' } },
    44|    { path: '/planning/budget/transactions', name: 'budget-transactions', component: BudgetTransactionsPage, meta: { title: 'Manuelle Budget-Transaktionen' } },
    45|    { path: '/planning/budget/accounts', name: 'budget-accounts', component: BudgetAccountsPage, meta: { title: 'Budget-Konten' } },
    46|    { path: '/planning/budget/categories', name: 'budget-categories', component: BudgetCategoriesPage, meta: { title: 'Budget-Kategorien' } },
    47|    { path: '/planning/budget/tags', name: 'budget-tags-later', ...later('Tags', 'Tag-Verwaltung ist als Budget Setup-Unterseite vorgesehen. Aktuell erfolgt sie im bestehenden Kategorien/Setup-Flow.') },
    48|    { path: '/planning/budget/setup', name: 'budget-setup', component: BudgetSetupPage, meta: { title: 'Excel übernehmen / Seed Review' } },
    49|    { path: '/planning/budget/plans', name: 'budget-plans-later', ...later('Budgetpläne', 'Budgetpläne sind produktiv übernommen; Detailpflege folgt als eigener Editor.') },
    50|    { path: '/planning/budget/monthly', name: 'budget-monthly-later', ...later('Monatsbudget', 'Monatsbudget ist im Budgetstatus je Kategorie sichtbar; Detailseite folgt.') },
    51|    { path: '/planning/budget/yearly', name: 'budget-yearly-later', ...later('Jahresbudget', 'Jahresbudget ist im Budgetstatus je Kategorie sichtbar; Detailseite folgt.') },
    52|    { path: '/planning/budget/income', name: 'budget-income', component: BudgetIncomePage, meta: { title: 'Einkünfte' } },
    53|    { path: '/planning/budget/income/planned', name: 'budget-income-planned', component: BudgetIncomePage, meta: { title: 'Geplante Einnahmen' } },
    54|    { path: '/planning/budget/income/actual', name: 'budget-income-actual', component: BudgetIncomePage, meta: { title: 'Effektive Einnahmen' } },
    55|    { path: '/planning/budget/income/forecast', name: 'budget-income-forecast', component: BudgetIncomePage, meta: { title: 'Einkommen Prognose' } },
    56|    { path: '/planning/budget/expenses/actual', name: 'budget-expenses-actual', component: BudgetTransactionsPage, meta: { title: 'Effektive Ausgaben' } },
    57|    { path: '/planning/budget/expenses/manual', name: 'budget-expenses-manual', component: BudgetTransactionsPage, meta: { title: 'Manuelle Ausgabe' } },
    58|    { path: '/planning/budget/expenses/review', name: 'budget-expense-review', component: BudgetImportReviewPage, meta: { title: 'Buchungen prüfen' } },
    59|    { path: '/planning/budget/transfers', name: 'budget-transfers', component: BudgetTransfersPage, meta: { title: 'Transfers' } },
    60|    { path: '/planning/budget/import-review', redirect: '/planning/budget/expenses/review' },
    61|    { path: '/planning/budget/category-assignment', redirect: '/planning/budget/expenses/review' },
    62|    { path: '/planning/budget/analysis/budget-vs-actual', name: 'budget-vs-actual', component: BudgetVsActualPage, meta: { title: 'Budget vs Ist' } },
    63|    { path: '/planning/budget/analysis/data-explorer', name: 'budget-data-explorer', component: DataExplorerPage, meta: { title: 'Daten-Explorer' } },
    64|    { path: '/planning/budget/analysis/categories', name: 'budget-category-analysis', component: CategoryAnalysisPage, meta: { title: 'Kategorieanalyse' } },
    65|    { path: '/planning/budget/analysis/monthly', name: 'budget-monthly-analysis', component: MonthlyComparisonPage, meta: { title: 'Monatsvergleich' } },
    66|    { path: '/planning/budget/analysis/forecast', name: 'budget-forecast', component: BudgetOverviewPage, meta: { title: 'Prognose' } },
    67|    { path: '/planning/budget/analysis/traffic-lights', name: 'budget-traffic-lights', component: BudgetOverviewPage, meta: { title: 'Ampeln' } },
    68|    { path: '/planning/budget/imports', name: 'budget-imports-later', ...later('Imports, später', 'VISA/Migros/Cumulus-Massenimporte bleiben später; aktuell nur kontrollierter Review-Seed.') },
    69|    { path: '/planning/budget/monthly-import', name: 'budget-monthly-import', component: BudgetMonthlyImportPage, meta: { title: 'Monatsimport' } },
    70|    { path: '/planning/budget/grocery-optimizer', name: 'budget-grocery-optimizer', component: BudgetGroceryOptimizerPage, meta: { title: 'Migros Optimizer' } },
    71|    { path: '/planning/budget/rules', name: 'budget-rules', component: BudgetRulesPage, meta: { title: 'Regeln' } },
    72|    { path: '/planning/budget/fixed-costs', name: 'budget-fixed-costs', component: BudgetRecurringPage, meta: { title: 'Fixkosten & Abos' } },
    73|    { path: '/planning/budget/recurring', redirect: '/planning/budget/fixed-costs' },
    74|    { path: '/planning/budget/duplicates', name: 'budget-duplicates-later', ...later('Duplikate, später', 'Minimale Duplikaterkennung existiert im Import Review; Detailmodul folgt später.') },
    75|    { path: '/planning/budget/migros-cumulus', name: 'budget-migros-later', ...later('Migros/Cumulus Details', 'Details folgen nach kontrolliertem Import-Review.') },
    76|    { path: '/planning/budget/visa-import', name: 'budget-visa-later', ...later('VISA Import', 'Kein VISA-Massenimport ohne Dry Run/Review.') },
    77|    { path: '/planning/finance', name: 'finance-later', ...later('Finanzplanung, später', 'Langfristige Planung, Ziele und Szenarien folgen später.') },
    78|    { path: '/analysis/performance', name: 'performance-later', ...later('Performance, später', 'Performancevergleich wird nicht in diesem Sprint gebaut.') },
    79|    { path: '/analysis/contribution', name: 'contribution-later', ...later('Contribution, später', 'Gewinnbeitrag / Contribution ist Roadmap, kein Fake-Inhalt.') },
    80|    { path: '/analysis/backtest', name: 'backtest-later', ...later('Backtest, später', 'Backtests sind Roadmap-Punkte.') },
    81|    { path: '/analysis/analysts', name: 'analysts-later', ...later('Analysten, später', 'Analystenbewertungen folgen später ohne Provider-Abruf beim Rendern.') },
    82|    { path: '/planning/goals', name: 'goals-later', ...later('Ziele, später', 'Finanzielle Ziele werden separat geplant.') }
    83|  ]
    84|})
    85|export default router
    86|


     1|<template><Toast /><ConfirmDialog /><AppLayout /></template>
     2|<script setup lang="ts">
     3|import Toast from 'primevue/toast'
     4|import ConfirmDialog from 'primevue/confirmdialog'
     5|import AppLayout from '@/components/AppLayout.vue'
     6|</script>
     7|


1|<template>
     2|

     3|

     4|      <SidebarNav />
     5|
     6|
     7|          <PageHeader eyebrow="User Dashboard" :title="title" description="Lokale Finance-Ansicht · externe Provider nur per expliziter Aktion">
     8|            <template #actions>
     9|              <PageHelpButton :help="help" />
    10|              Read-only View
    11|            </template>
    12|          </PageHeader>
    13|
    14|
<RouterView />

    15|
    16|

    17|

    18|</template>
    19|
    30|


1|<template>
     2|
     3|

     4|
JARVIS

     5|
Finance

     6|
PrimeVue Aura Light

     7|

     8|
     9|      Budget Übersicht Budget vs Ist Grocery Optimizer Reports Wallets Portfolio Crypto Equity
    10|      {{ item.label }}
    11|      <template v-for="group in budgetGroups" :key="group.title">
    12|
{{ group.title }}

    13|        {{ item.label }}
    14|      </template>
    15|
    16|
    17|
User Flow

    18|
Lokale Daten automatisch geladen.

    19|
Schreibseiten: Preview/Confirm/Audit.

    20|
    21|
    22|
    23|  <button data-testid="mobile-menu-button" type="button" class="pointer-events-auto fixed right-3 top-3 z-[95] min-h-12 rounded-2xl border border-slate-200 bg-white/95 px-4 py-2 text-sm font-bold text-slate-900 shadow-2xl backdrop-blur lg:hidden" @click="menuOpen = true">Menü</button>
    24|

    25|

    26|

JARVIS

Mobiles Menü

    27|      <button data-testid="mobile-menu-close" type="button" class="min-h-12 rounded-xl border border-slate-300 px-4 py-2 font-semibold" @click="menuOpen = false">Schließen</button>
    28|

    29|
    30|      <a v-for="item in allMobileMenuItems" :key="item.to" :href="item.to" :data-testid="`mobile-menu-link-${item.key}`" class="block min-h-14 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-base font-semibold text-slate-900 active:bg-cyan-50" :class="isActive(item.to) ? 'ring-2 ring-cyan-300' : ''">{{ item.label }}{{ item.to }}
    31|
    32|

    33|
    34|
    35|    <a v-for="item in mobileItems" :key="item.to" :href="item.to" :data-testid="`mobile-nav-${item.key}`" class="mobile-nav-link min-h-12 rounded-xl px-1 py-2 font-medium transition active:bg-cyan-50" :class="isActive(item.to) ? 'mobile-nav-active' : ''" @click="moreOpen = false">{{ item.label }}
    36|

    37|      Reports Wallets Einkünfte Kategorien Aktien/ETF & Cash Buchungen prüfen
    38|      <button data-testid="mobile-more-button" type="button" class="min-h-12 w-full rounded-xl px-1 py-2 font-medium text-slate-600 transition active:bg-cyan-50" :class="moreOpen ? 'bg-cyan-50 text-cyan-800 ring-1 ring-cyan-200' : ''" @click="moreOpen = !moreOpen">Mehr</button>
    39|

    40|        <a v-for="item in moreItems" :key="item.to" :href="item.to" :data-testid="`mobile-more-${item.key}`" class="block min-h-11 w-full rounded-xl px-3 py-3 text-left text-sm text-slate-700 hover:bg-slate-100 active:bg-cyan-50" :class="isActive(item.to) ? 'mobile-more-active' : ''" @click="moreOpen = false">{{ item.label }}
    41|

    42|

    43|
    44|</template>
    45|
    56|
    57|


     1|export type UserNavItem = { key: string; to: string; label: string }
     2|export type UserNavGroup = { title: string; items: UserNavItem[] }
     3|
     4|export const mainItems: UserNavItem[] = [
     5|  { key: 'home', to: '/', label: 'Command Center' },
     6|  { key: 'portfolio', to: '/portfolio', label: 'Portfolio' },
     7|  { key: 'crypto', to: '/crypto', label: 'Crypto' },
     8|  { key: 'equity', to: '/equity', label: 'Aktien & ETFs' },
     9|  { key: 'wallets', to: '/wallets', label: 'Wallets' },
    10|  { key: 'position-add', to: '/positions/add', label: 'Position hinzufügen' },
    11|  { key: 'reports', to: '/reports', label: 'Reports' },
    12|]
    13|
    14|export const budgetGroups: UserNavGroup[] = [
    15|  { title: 'Budget', items: [
    16|    { key: 'budget-overview', to: '/planning/budget', label: 'Budget Übersicht' },
    17|    { key: 'budget-status', to: '/planning/budget/status', label: 'Budgetstatus' },
    18|    { key: 'budget-planning', to: '/planning/budget/planning', label: 'Budgetplanung' },
    19|    { key: 'fixed-costs', to: '/planning/budget/fixed-costs', label: 'Fixkosten & Abos' },
    20|    { key: 'budget-vs-actual', to: '/planning/budget/analysis/budget-vs-actual', label: 'Budget vs Ist' },
    21|    { key: 'budget-monthly', to: '/planning/budget/monthly', label: 'Monatsbudget' },
    22|    { key: 'budget-yearly', to: '/planning/budget/yearly', label: 'Jahresbudget' },
    23|    { key: 'budget-plans', to: '/planning/budget/plans', label: 'Budgetpläne' },
    24|  ] },
    25|  { title: 'Transaktionen', items: [
    26|    { key: 'monthly-import', to: '/planning/budget/monthly-import', label: 'Monatsimport' },
    27|    { key: 'expenses-actual', to: '/planning/budget/expenses/actual', label: 'Effektive Ausgaben' },
    28|    { key: 'income-actual', to: '/planning/budget/income/actual', label: 'Effektive Einnahmen' },
    29|    { key: 'review', to: '/planning/budget/expenses/review', label: 'Buchungen prüfen' },
    30|    { key: 'transfers', to: '/planning/budget/transfers', label: 'Transfers' },
    31|  ] },
    32|  { title: 'Analyse', items: [
    33|    { key: 'data-explorer', to: '/planning/budget/analysis/data-explorer', label: 'Daten-Explorer' },
    34|    { key: 'grocery', to: '/planning/budget/grocery-optimizer', label: 'Migros Optimizer' },
    35|    { key: 'category-analysis', to: '/planning/budget/analysis/categories', label: 'Kategorieanalyse' },
    36|    { key: 'monthly-analysis', to: '/planning/budget/analysis/monthly', label: 'Monatsvergleich' },
    37|  ] },
    38|  { title: 'Setup', items: [
    39|    { key: 'accounts', to: '/planning/budget/accounts', label: 'Konten' },
    40|    { key: 'categories', to: '/planning/budget/categories', label: 'Kategorien & Tags' },
    41|    { key: 'rules', to: '/planning/budget/rules', label: 'Regeln' },
    42|    { key: 'setup', to: '/planning/budget/setup', label: 'Excel übernehmen / Seed Review' },
    43|  ] },
    44|]
    45|
    46|export const mobileItems: UserNavItem[] = [
    47|  { key: 'home', to: '/', label: 'Home' },
    48|  { key: 'budget', to: '/planning/budget', label: 'Budget' },
    49|  { key: 'expenses', to: '/planning/budget/expenses/actual', label: 'Ausgaben' },
    50|  { key: 'crypto', to: '/crypto', label: 'Crypto' },
    51|  { key: 'portfolio', to: '/portfolio', label: 'Portfolio' },
    52|]
    53|
    54|export const moreItems: UserNavItem[] = [
    55|  { key: 'reports', to: '/reports', label: 'Reports' },
    56|  { key: 'wallets', to: '/wallets', label: 'Wallets' },
    57|  { key: 'income', to: '/planning/budget/income', label: 'Einkünfte' },
    58|  { key: 'categories', to: '/planning/budget/categories', label: 'Kategorien' },
    59|  { key: 'equity', to: '/equity', label: 'Aktien/ETF & Cash' },
    60|  { key: 'review', to: '/planning/budget/expenses/review', label: 'Buchungen prüfen' },
    61|  { key: 'transfers', to: '/planning/budget/transfers', label: 'Transfers' },
    62|  { key: 'budget-vs-actual', to: '/planning/budget/analysis/budget-vs-actual', label: 'Budget vs Ist' },
    63|  { key: 'budget-planning', to: '/planning/budget/planning', label: 'Budgetplanung' },
    64|  { key: 'fixed-costs', to: '/planning/budget/fixed-costs', label: 'Fixkosten & Abos' },
    65|  { key: 'data-explorer', to: '/planning/budget/analysis/data-explorer', label: 'Daten-Explorer' },
    66|  { key: 'grocery', to: '/planning/budget/grocery-optimizer', label: 'Migros Optimizer' },
    67|]
    68|
    69|export const allMobileMenuItems: UserNavItem[] = [
    70|  { key: 'home', to: '/', label: 'Home' },
    71|  { key: 'budget', to: '/planning/budget', label: 'Budget' },
    72|  { key: 'expenses', to: '/planning/budget/expenses/actual', label: 'Ausgaben' },
    73|  { key: 'review', to: '/planning/budget/expenses/review', label: 'Buchungen prüfen' },
    74|  { key: 'transfers', to: '/planning/budget/transfers', label: 'Transfers' },
    75|  { key: 'income', to: '/planning/budget/income', label: 'Einkünfte' },
    76|  { key: 'crypto', to: '/crypto', label: 'Crypto' },
    77|  { key: 'portfolio', to: '/portfolio', label: 'Portfolio' },
    78|  { key: 'equity', to: '/equity', label: 'Aktien & ETFs' },
    79|  { key: 'wallets', to: '/wallets', label: 'Wallets' },
    80|  { key: 'reports', to: '/reports', label: 'Reports' },
    81|  { key: 'categories', to: '/planning/budget/categories', label: 'Kategorien' },
    82|  { key: 'rules', to: '/planning/budget/rules', label: 'Regeln' },
    83|  { key: 'budget-planning', to: '/planning/budget/planning', label: 'Budgetplanung' },
    84|  { key: 'fixed-costs', to: '/planning/budget/fixed-costs', label: 'Fixkosten & Abos' },
    85|  { key: 'data-explorer', to: '/planning/budget/analysis/data-explorer', label: 'Daten-Explorer' },
    86|  { key: 'category-analysis', to: '/planning/budget/analysis/categories', label: 'Kategorieanalyse' },
    87|  { key: 'monthly-analysis', to: '/planning/budget/analysis/monthly', label: 'Monatsvergleich' },
    88|  { key: 'grocery', to: '/planning/budget/grocery-optimizer', label: 'Migros Optimizer' },
    89|]
    90|
    91|export const allUserNavItems: UserNavItem[] = [
    92|  ...mainItems,
    93|  ...budgetGroups.flatMap((group) => group.items),
    94|  ...mobileItems,
    95|  ...moreItems,
    96|  ...allMobileMenuItems,
    97|]
    98|


Explanation:
**Considering implementation options**

I'm thinking about whether I need to use a delegate for this task. Maybe I should implement multiple delegates if necessary. I wonder if I can use something like execute_code for patching, but I also need to take file tools into account. It seems important to inspect the test setup for the frontend as well. There’s a lot to consider, and I want to make sure I’m covering all my bases!


1|<template>
     2|

     3|
     4|

     5|
Budget · Monatsimport

     6|
Budget Monthly Import

     7|
Google-Drive-CSV Workflow: Scan → Dry Run → Kandidaten → Review → Confirm → Audit. Keine produktive Buchung beim Import.

     8|

     9|

    10|        <button class="btn" @click="load">Aktualisieren</button>
    11|        <button class="btn-primary" @click="scanDrive">Drive-Ordner scannen</button>
    12|

    13|
    14|    <Message v-if="message" severity="success" :closable="false">{{ message }}</Message>
    15|    <Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
    16|
    17|

    18|      <Card><template #content>
Offene Kandidaten
{{ totalOpen }}</template></Card>
    19|      <Card><template #content>
Ready for Confirm
{{ dashboard?.counts.ready_for_confirm || dashboard?.counts.auto_categorized || 0 }}</template></Card>
    20|      <Card><template #content>
Needs Review
{{ dashboard?.counts.needs_review || 0 }}</template></Card>
    21|      <Card><template #content>
Regelvorschläge
{{ dashboard?.rule_suggestions_open || 0 }}</template></Card>
    22|

    23|
    24|

    25|
Monatsimport Dashboard

    26|

    27|

    28|
{{ source }}

    29|          {{ session.status }}
    30|
{{ session.file_name }}

    31|

    32|

    33|      Zu Buchungen prüfen
    34|

    35|
    36|

    37|
Dry Run / Kandidatenanlage

    38|
Für Browser-Sicherheit nutzt diese UI eine Demo-Payload. Produktiv scannt der Backend-Adapter nur Drive-Metadaten; echte CSV-Dateien bleiben runtime/temp-only und gehen nie ins Git.

    39|
<button class="btn" @click="dryRunDemo">Dry Run erstellen</button><button class="btn-primary" @click="createDemoCandidates">Neue Kandidaten erzeugen</button>Review mit Filter öffnen

    40|
Status: {{ lastResult.status }}
Dateien: {{ lastResult.files_total }} · Kandidaten: {{ lastResult.totals.candidate_count || lastResult.totals.would_create_candidate_count || 0 }} · Bekannt: {{ lastResult.totals.already_processed_count || 0 }}

    41|

    42|
    43|

    44|
Import-Historie

    45|

Datum	Quelle	Datei	Profil	Zeilen	Neu	Bekannt	Duplikate	Status	Review

{{ h.created_at }}	{{ h.source }}	{{ h.file_name }}	{{ h.profile }}	{{ h.rows_total }}	{{ h.new_candidates }}	{{ h.already_known }}	{{ h.duplicate_count }}	{{ h.status }}	öffnen

    46|

{{ h.source }} · {{ h.status }}
{{ h.file_name }} · Zeilen {{ h.rows_total }} · Neu {{ h.new_candidates }}
Review öffnen

    47|

    48|

    49|</template>
    50|
    65|
    66|


     1|import { apiGet, apiPost } from './client'
     2|
     3|export type BudgetAccount = { budget_account_id: string; name: string; account_type: string; currency: string; is_active: boolean; linked_account_id?: string | null; archived_at?: string | null; notes?: string | null }
     4|export type BudgetCategory = { category_id: string; parent_category_id?: string | null; name: string; category_type: string; is_active: boolean; sort_order?: number; color?: string | null; icon?: string | null; transaction_count?: number; expense_sum_chf?: string | number; active_plan_count?: number; merchant_count?: number; rule_count?: number; children?: BudgetCategory[] }
     5|export type BudgetTag = { tag_id: string; name: string; color?: string | null; is_active: boolean }
     6|export type BudgetTransaction = { budget_transaction_id: string; account_id?: string; account_name?: string; category_id?: string | null; category_name?: string | null; category_icon?: string | null; category_color?: string | null; transaction_date: string; transaction_date_group?: string; transaction_type: string; description: string; payee?: string | null; merchant_display_name?: string | null; amount_original: string; currency_original: string; amount_chf?: string | null; fx_status?: string; source_type?: string; source_candidate_id?: string | null; status: string; tag_names?: string[]; line_item_count?: number; article_rows_detail_only?: boolean }
     7|export type BudgetTransfer = { transfer_id: string; transaction_date: string; from_account_id: string; to_account_id: string; from_account_name: string; to_account_name: string; description: string; amount_original: string; currency_original: string; transfer_type: string; status: string; notes?: string | null }
     8|export type BudgetMonthlyAnalysis = { month: string; month_label: string; actual_chf: string; budget_chf: string; difference_chf: string; status: string; bar_color: 'green' | 'yellow' | 'red' | 'slate' | string }
     9|export type BudgetMonthDetail = { category_id: string; category: string; category_type: string; month: string; budget_month: string; actual_chf: string; difference_chf: string; difference_percent: string; status: string; bar_color: string; breakdown_mode: string; breakdown: Array<{ label: string; amount_chf: string; share_percent: string }>; transactions: BudgetTransaction[]; expense_filter_url: string }
    10|export type BudgetPlanningRow = { category_id: string; category: string; category_type: string; budget_month_chf?: string | null; budget_year_chf?: string | null; budget_cadence?: string | null; has_budget: boolean; actual_current_month_chf: string; actual_months_2026: Record<string, string>; actual_year_to_date_chf: string; actual_avg_month_chf: string; linear_forecast_chf: string; budget_adjusted_forecast_chf: string; forecast_year_chf: string; previous_year_months: Record<string, string>; previous_year_total_chf: string; previous_year_monthly_average_chf: string; has_previous_year: boolean; actual_vs_budget_chf: string; forecast_vs_budget_chf: string; current_month_vs_budget_chf: string; current_month_vs_previous_year_chf: string; ytd_vs_previous_year_chf: string; forecast_vs_previous_year_chf: string; status: string; status_color: string; plausibility_checks?: Array<{ severity: string; code: string; message: string }>; data_explorer_url: string; effective_expenses_url: string; monthly_detail_url: string; monthly_chart: Array<{ month: string; actual_2026_chf: string; budget_chf: string; actual_2025_chf: string; deviation_chf: string }> }
    11|export type BudgetPlanningMatrix = { purpose: 'budget_planning_forecast_v1'; year: string; previous_year: string; current_month: string; forecast_method: string; forecast_method_help?: Record<string, string>; rows: BudgetPlanningRow[]; categories_without_budget: string[]; categories_without_previous_year: string[]; categories_with_actuals_without_budget: string[]; categories_with_budget_without_actuals: string[]; categories_with_previous_year_without_budget?: string[] }
    12|export type BudgetPlanningAudit = { category_id: string; category?: string; entries: Array<{ timestamp: string; action_label: string; source: string; note?: string; created_by: string; changes: Array<{ field_label: string; old_value: string; new_value: string }>; technical?: { audit_id: string } }> }
    13|export type BudgetRecurringPayment = { recurring_id: string; name: string; category_id: string; category_name?: string | null; account_id?: string | null; merchant_id?: string | null; expected_amount_text: string; expected_amount_chf?: string; currency: string; frequency: string; expected_day_of_month?: number | null; expected_month?: number | null; amount_tolerance_pct: string; date_tolerance_days: number; recurring_type: string; status: string; source: string; confidence?: string; last_seen_date?: string | null; next_expected_date?: string | null; notes?: string | null; budget_month_chf?: string; budget_year_chf?: string }
    14|export type BudgetRecurringDashboard = { purpose: 'fixed_costs_subscriptions_v1'; candidates: BudgetRecurringPayment[]; active: BudgetRecurringPayment[]; warnings: Array<Record<string, string>>; category_breakdown: Array<Record<string, string | number>>; kpis?: { monthly_fixed_costs_chf?: string; annual_fixed_costs_chf?: string; fixed_cost_quote_percent?: string; active_recurring_count?: number; open_candidate_count?: number; missing_payment_count?: number; next_due?: BudgetRecurringPayment | null; largest_fixed_costs?: BudgetRecurringPayment[] }; analytics: Record<string, any> }
    15|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 }
    16|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 }
    17|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 }
    18|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 }
    19|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 }
    20|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 }
    21|export type GroceryProductItem = { product_item_id: string; receipt_id: string; purchase_date: string; store_name: string; raw_product_name: string; normalized_product_name: string; quantity_text?: string; unit?: string; unit_price_text?: string; total_price_text: string; action_label?: string; include_in_analysis: number; health_analysis_status: string; known_mappings?: GroceryMapping[] }
    22|export type GroceryReceipt = { receipt_id: string; purchase_date: string; store_name: string; article_count: number; total_text: string; items: GroceryProductItem[] }
    23|export type GroceryDashboard = { purpose: 'migros_product_savings_grocery_optimizer_v1'; receipts: GroceryReceipt[]; supported_retailers: string[]; health_analysis_status: string; disclaimer: string }
    24|export type GroceryRun = { run_id: string; receipt_id: string; original_total_text: string; optimized_total_text: string; quality_status: string; summary: Record<string, any>; optimized_lists: Array<Record<string, any>>; product_comparisons: Array<Record<string, any>>; health_analysis_status: string }
    25|export type GroceryOnlinePriceMatch = { match_id?: string; mapping_id?: string; product_item_id: string; retailer: string; product_name: string; product_url: string; price_text?: string; unit_price_text?: string; fetched_at?: string; quality_flags?: string[]; confidence?: string; status: string; source?: string; cache_status?: string }
    26|export type GroceryOnlinePriceSearch = { receipt_id: string; provider_calls: number; cache_hits: number; matches: GroceryOnlinePriceMatch[] }
    27|export type BudgetPreview = { preview_id: string; summary: string; fx_status?: string; warnings: string[]; payload: Record<string, unknown>; review?: Record<string, string>; requires_explicit_confirm?: boolean }
    28|export type BudgetConfirm = { status: string; entity_id: string; audit_id: string; message: string }
    29|export type BudgetSeedCandidate = {
    30|  seed_candidate_id: string
    31|  source_file_label?: string
    32|  source_sheet: string
    33|  source_row_or_range?: string | null
    34|  candidate_type: 'category' | 'budget_plan' | 'recurring_candidate' | 'unclean_range'
    35|  source_label?: string | null
    36|  proposed_category_id?: string | null
    37|  proposed_parent_label?: string | null
    38|  proposed_name?: string | null
    39|  proposed_period_type?: string | null
    40|  proposed_amount_text?: string | null
    41|  currency?: string | null
    42|  confidence: string
    43|  requires_review: boolean
    44|  status: string
    45|  notes?: string | null
    46|}
    47|export type BudgetSeedSummary = { category_candidate_count: number; budget_plan_candidate_count: number; recurring_candidate_count: number; unclean_range_count: number; current_budget_sheet?: string }
    48|export type BudgetStatusRow = { purpose?: string; category_id: string; category: string; category_type: string; budget_month?: string | null; budget_year?: string | null; has_budget?: boolean; actual_current_month?: string; actual_year_to_date: string; monthly_actuals?: Record<string, string>; monthly_analysis?: BudgetMonthlyAnalysis[]; actual_avg_month: string; forecast_year: string; linear_forecast?: string; forecast_avg_month: string; deviation_year: string; deviation_month: string; actual_2025_year?: string; previous_year_total?: string; previous_year_months?: Record<string, string>; deviation_previous_year?: string; has_previous_year?: boolean; data_explorer_url?: string; effective_expenses_url?: string; monthly_chart?: Array<Record<string, string>>; status: string; status_hint?: string; months_with_bookings: number; plan_names?: string[]; is_rollup?: boolean; rollup_child_count?: number }
    49|export type BudgetTransactionCandidate = { transaction_candidate_id: string; source_file_label: string; source_row_or_range?: string | null; source_type: string; transaction_date?: string | null; description: string; merchant?: string | null; amount_original?: string | null; currency_original: string; proposed_category_id?: string | null; proposed_category_name?: string | null; duplicate_of_transaction_id?: string | null; confidence: string; requires_review: boolean; status: string; notes?: string | null; classification?: string | null; review_reason?: string | null; rule_id?: string | null; rule_name?: string | null; account_source?: string | null; covered_by_source?: string | null; linked_candidate_id?: string | null }
    50|export type BudgetReviewSummary = { open_total: number; auto_categorized: number; review_needed: number; galaxus_digitec: number; migros_over_50: number; covered_by_migros: number; unclear: number; possible_duplicates: number }
    51|export type BudgetRule = { rule_id: string; merchant_contains: string; source_type?: string | null; category_id?: string | null; category_name?: string | null; target_status: string; is_active: boolean; priority?: number; confidence?: string | null; notes?: string | null }
    52|export type BudgetMerchant = { merchant_id: string; display_name: string; normalized_name: string; default_category_id?: string | null; default_category_name?: string | null; is_active: boolean; notes?: string | null; alias_count?: number; open_candidate_count?: number }
    53|export type BudgetCharts = { year: string; empty_state: boolean; cashflow_by_month: Array<{ year: string; month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; budget_vs_actual_by_category: Array<{ year: string; category_id: string; category: string; category_type: string; budget_year?: string | null; actual_year_to_date: string; forecast_year: string; status: string }>; forecast_overruns: Array<Record<string, string>> }
    54|export type CategoryAnalysis = { year: string; month?: string | null; mode: 'income' | 'expense'; total_chf: string; categories: Array<{ category_id: string; category: string; amount_chf: string; share_percent: string; transaction_count: number; color?: string | null; icon?: string | null; transactions_url: string }> }
    55|export type MonthlyComparison = { year: string; months: Array<{ month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string; savings_rate_percent?: string; cumulative_income_chf?: string; cumulative_expense_chf?: string; transaction_count: number; top_categories: Array<{ category: string; amount_chf: string }>; top_merchants: Array<{ merchant: string; amount_chf: string }>; largest_transactions?: Array<Record<string, string>>; transactions_url: string }> }
    56|export type BudgetDataExplorer = { purpose: 'budget_data_explorer'; filters: Record<string, string>; kpis: { income_sum_chf: string; expense_sum_chf: string; net_cashflow_chf: string; transaction_count: number; average_transaction_chf: string; largest_transaction_chf: string; largest_transaction_id?: string | null; top_category: string; top_merchant: string }; transactions: BudgetTransaction[]; top_merchants: Array<{ merchant: string; transaction_count: number; sum_chf: string; average_chf: string; last_transaction_date?: string | null; top_category: string; change_vs_previous_month_chf: string }>; fixed_variable: Record<string, string>; export: { enabled: boolean; target: string; message: string } }
    57|export type IncomePlan = { plan_item_id: string; name: string; person_source: string; employer_source?: string; income_type: string; target_month?: string; cadence: string; sort_order?: number; monthly_value_chf: string; annual_value_chf: string; monthly_average_chf?: string; actual_income_year_to_date_chf?: string; forecast_income_year_chf?: string; deviation_year_chf?: string; status_label?: string; category_id: string; category_name: string; is_active: boolean; is_template?: boolean; notes?: string | null }
    58|export type IncomeSeedCandidate = { seed_candidate_id: string; candidate_type: string; proposed_name?: string | null; proposed_parent_label?: string | null; confidence: string; requires_review: boolean; status: string; source_sheet?: string; source_row_or_range?: string | null }
    59|export type BudgetOverview = { current_month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string; review_count: number; uncategorized_count: number; pending_seed_candidate_count?: number; pending_transaction_candidate_count?: number; planned_income_chf?: string; planned_expense_chf?: string; planned_net_chf?: string; actual_income_chf?: string; actual_expense_chf: string; actual_net_chf?: string; actuals_label?: string; open_categories_without_actuals?: number; planned_budget_chf: string; remaining_budget_chf: string; budget_status: string; budget_categories: BudgetStatusRow[]; uncategorized_transactions: BudgetTransaction[]; top_categories: Array<{ category_id?: string; category_name: string; amount_chf: string; transaction_count?: number }>; recent_transactions: BudgetTransaction[] }
    60|export type BudgetDashboardCockpit = { purpose: 'monthly_cockpit'; month: string; year: string; chart_library: string; kpis: { income_current_month_chf: string; expense_current_month_chf: string; net_cashflow_current_month_chf: string; budget_consumption_percent: string; savings_rate_percent: string; open_review_transactions: number; fixed_costs_month_chf?: string; fixed_cost_quote_percent?: string; open_fixed_cost_candidate_count?: number; missing_fixed_cost_payment_count?: number }; monthly_cashflow: { income_chf: string; expense_chf: string; net_cashflow_chf: string }; top_expense_categories: Array<{ category_id: string; category: string; amount_chf: string; share_percent: string; transaction_count: number; color?: string | null; icon?: string | null }>; critical_categories: BudgetStatusRow[]; recent_confirmed_expenses: BudgetTransaction[]; open_todos: { review_candidates: number; categories_without_budget: number; unconfirmed_transactions: number } }
    61|export type BudgetVsActualAnalysis = { purpose: 'plan_vs_reality_analysis'; year: string; chart_library: string; empty_state: boolean; budget_vs_actual_by_category: BudgetStatusRow[]; top_overruns: Array<Record<string, string>>; top_under_budget: Array<Record<string, string>>; cashflow_by_month: Array<{ month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; category_donut: Array<{ category_id: string; category: string; amount_chf: string; share_percent: string; color?: string | null }>; trend_by_month: Array<{ month: string; cumulative_income_chf: string; cumulative_expense_chf: string; cumulative_net_chf: string }> }
    62|
    63|export const getBudgetDashboardCockpit = (month = '2026-05') => apiGet<BudgetDashboardCockpit>(`/api/budget/dashboard/cockpit?month=${month}`)
    64|export const getBudgetPlanningMatrix = (year = '2026', currentMonth = '') => apiGet<BudgetPlanningMatrix>(`/api/budget/planning/matrix?year=${year}${currentMonth ? `&current_month=${encodeURIComponent(currentMonth)}` : ''}`)
    65|export const getBudgetPlanningAudit = (categoryId: string) => apiGet<BudgetPlanningAudit>(`/api/budget/planning/categories/${encodeURIComponent(categoryId)}/audit`)
    66|export const previewBudgetPlanningCategory = (categoryId: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/planning/categories/${encodeURIComponent(categoryId)}/preview`, body)
    67|export const confirmBudgetPlanningCategory = (categoryId: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/planning/categories/${encodeURIComponent(categoryId)}/confirm`, body)
    68|export const previewBudgetPlanningBatch = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/planning/batch/preview', body)
    69|export const confirmBudgetPlanningBatch = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/planning/batch/confirm', body)
    70|export const previewBudgetPlanningExcelTemplate = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/planning/excel-template/preview', body)
    71|export const getBudgetRecurringDashboard = () => apiGet<BudgetRecurringDashboard>('/api/budget/recurring')
    72|export const getBudgetMonthlyImportDashboard = () => apiGet<BudgetMonthlyImportDashboard>('/api/budget/monthly-import/dashboard')
    73|export const getGroceryOptimizerDashboard = () => apiGet<GroceryDashboard>('/api/budget/grocery-optimizer/dashboard')
    74|export const getGroceryProviderStrategy = () => apiGet<Record<string, unknown>>('/api/budget/grocery-optimizer/provider-strategy')
    75|export const getGroceryRappnLink = (productName: string) => apiGet<Record<string, unknown>>(`/api/budget/grocery-optimizer/rappn-link?product_name=${encodeURIComponent(productName)}`)
    76|export const searchOpenPricesSpike = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/open-prices-spike', body)
    77|export const createGroceryDemoReceipt = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/demo-receipt', body)
    78|export const addGroceryProductMatch = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/matches', body)
    79|export const searchGroceryOnlinePrices = (body: unknown) => apiPost<GroceryOnlinePriceSearch>('/api/budget/grocery-optimizer/search-prices', body)
    80|export const acceptGroceryProductMatch = (body: { match_id: string; save_as_preferred?: boolean; user_note?: string }) => apiPost<Record<string, unknown>>(`/api/budget/grocery-optimizer/matches/${encodeURIComponent(body.match_id)}/accept`, body)
    81|export const rejectGroceryProductMatch = (body: { match_id: string; user_note?: string }) => apiPost<Record<string, unknown>>(`/api/budget/grocery-optimizer/matches/${encodeURIComponent(body.match_id)}/reject`, body)
    82|export const previewManualGroceryMapping = (body: unknown) => apiPost<GroceryManualMappingPreview>('/api/budget/grocery-optimizer/manual-mapping/preview', body)
    83|export const confirmManualGroceryMapping = (body: GroceryManualMappingPreview) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/manual-mapping/confirm', body)
    84|export const saveManualGroceryMapping = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/manual-mapping', body)
    85|export const refreshGroceryMappingPrice = (body: { mapping_id: string }) => apiPost<Record<string, unknown>>(`/api/budget/grocery-optimizer/mappings/${encodeURIComponent(body.mapping_id)}/refresh-price`, {})
    86|export const runGroceryOptimization = (body: unknown) => apiPost<GroceryRun>('/api/budget/grocery-optimizer/optimize', body)
    87|export const generateGroceryOptimizationReport = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/report', body)
    88|export const getBudgetImportHistory = () => apiGet<BudgetImportSession[]>('/api/budget/monthly-import/history')
    89|export const scanBudgetMonthlyImportDrive = (body: unknown = {}) => apiPost<Record<string, unknown>>('/api/budget/monthly-import/drive-scan', body)
    90|export const dryRunBudgetMonthlyImport = (body: unknown) => apiPost<BudgetMonthlyImportResult>('/api/budget/monthly-import/dry-run', body)
    91|export const confirmBudgetMonthlyImportCandidates = (body: unknown) => apiPost<BudgetMonthlyImportResult>('/api/budget/monthly-import/confirm-candidates', body)
    92|export const getBudgetRuleSuggestions = () => apiGet<BudgetRuleSuggestion[]>('/api/budget/rules/suggestions')
    93|export const applyBudgetRuleSuggestion = (id: string, applyToOpen = true) => apiPost<Record<string, unknown>>(`/api/budget/rules/suggestions/${encodeURIComponent(id)}/apply`, { apply_to_open: applyToOpen })
    94|export const rejectBudgetRuleSuggestion = (id: string, later = false) => apiPost<Record<string, unknown>>(`/api/budget/rules/suggestions/${encodeURIComponent(id)}/reject`, { later })
    95|export const detectBudgetRecurringCandidates = () => apiPost<Record<string, unknown>>('/api/budget/recurring/detect', {})
    96|export const previewBudgetRecurringCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/recurring/candidates/${encodeURIComponent(id)}/preview`, body)
    97|export const confirmBudgetRecurringCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/recurring/candidates/${encodeURIComponent(id)}/confirm`, body)
    98|export const previewManualRecurringPayment = (body: unknown) => apiPost<BudgetPreview>('/api/budget/recurring/manual/preview', body)
    99|export const confirmManualRecurringPayment = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/recurring/manual/confirm', body)
   100|export const archiveRecurringPayment = (id: string) => apiPost<BudgetConfirm>(`/api/budget/recurring/${encodeURIComponent(id)}/archive`, {})
   101|export const getBudgetOverview = (month = '2026-01') => apiGet<BudgetOverview>(`/api/budget/overview?month=${month}`)
   102|export const getBudgetStatusByCategory = (year = '2026') => apiGet<BudgetStatusRow[]>(`/api/budget/status/categories?year=${year}`)
   103|export const getCategoryMonthDetail = (categoryId: string, month: string) => apiGet<BudgetMonthDetail>(`/api/budget/status/categories/${encodeURIComponent(categoryId)}/months/${encodeURIComponent(month)}`)
   104|export const getBudgetCharts = (year = '2026') => apiGet<BudgetCharts>(`/api/budget/charts?year=${year}`)
   105|export const getBudgetVsActualAnalysis = (year = '2026') => apiGet<BudgetVsActualAnalysis>(`/api/budget/analysis/budget-vs-actual?year=${year}`)
   106|export const getCategoryAnalysis = (year = '2026', txType: 'income' | 'expense' = 'expense', month = '') => {
   107|  const params = new URLSearchParams({ year, tx_type: txType })
   108|  if (month) params.set('month', month)
   109|  return apiGet<CategoryAnalysis>(`/api/budget/analysis/categories?${params.toString()}`)
   110|}
   111|export const getMonthlyComparison = (year = '2026') => apiGet<MonthlyComparison>(`/api/budget/analysis/monthly-comparison?year=${year}`)
   112|export const getBudgetDataExplorer = (filters: Record<string, string> = {}) => {
   113|  const params = new URLSearchParams()
   114|  Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value) })
   115|  return apiGet<BudgetDataExplorer>(`/api/budget/analysis/data-explorer?${params.toString()}`)
   116|}
   117|export const getIncomeStatus = (year = '2026') => apiGet<Record<string, unknown>>(`/api/budget/income/status?year=${year}`)
   118|export const getIncomePlans = (year = '2026') => apiGet<IncomePlan[]>(`/api/budget/income/plans?year=${year}`)
   119|export const getIncomeSeedCandidates = (year = '2026') => apiGet<IncomeSeedCandidate[]>(`/api/budget/income/seed-candidates?year=${year}`)
   120|export const previewIncomePlan = (body: unknown) => apiPost<BudgetPreview>('/api/budget/income/plans/preview', body)
   121|export const confirmIncomePlan = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/income/plans/confirm', body)
   122|export const previewIncomeTemplates = (year = '2026') => apiPost<Record<string, unknown>>('/api/budget/income/templates/preview', { year })
   123|export const confirmIncomeTemplates = (year = '2026') => apiPost<Record<string, unknown>>('/api/budget/income/templates/confirm', { year })
   124|export const previewIncomePlanUpdate = (id: string, body: unknown) => apiPost<BudgetPreview & { requires_explicit_confirm?: boolean }>(`/api/budget/income/plans/${id}/update/preview`, body)
   125|export const confirmIncomePlanUpdate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/income/plans/${id}/update/confirm`, body)
   126|export const reorderIncomePlans = (plan_item_ids: string[], year = '2026') => apiPost<BudgetConfirm & { updated_count: number }>('/api/budget/income/plans/reorder', { plan_item_ids, year })
   127|export const archiveIncomePlan = (id: string) => apiPost<BudgetConfirm>(`/api/budget/income/plans/${id}/archive`, {})
   128|export const getBudgetTransactions = (query = '') => apiGet<BudgetTransaction[]>(`/api/budget/transactions${query}`)
   129|export const getBudgetTransfers = (query = '') => apiGet<BudgetTransfer[]>(`/api/budget/transfers${query}`)
   130|export const getBudgetAccounts = () => apiGet<BudgetAccount[]>('/api/budget/accounts')
   131|export const getBudgetCategories = () => apiGet<BudgetCategory[]>('/api/budget/categories')
   132|export const getBudgetTags = () => apiGet<BudgetTag[]>('/api/budget/tags')
   133|export const previewBudgetTransaction = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transactions/preview', body)
   134|export const confirmBudgetTransaction = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transactions/confirm', body)
   135|export const updateBudgetTransaction = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transactions/${id}/update`, body)
   136|export const previewBudgetTransfer = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transfers/preview', body)
   137|export const confirmBudgetTransfer = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transfers/confirm', body)
   138|export const previewBudgetAccount = (body: unknown) => apiPost<BudgetPreview>('/api/budget/accounts/preview', body)
   139|export const confirmBudgetAccount = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/accounts/confirm', body)
   140|export const archiveBudgetAccount = (id: string) => apiPost<BudgetConfirm>(`/api/budget/accounts/${id}/archive`, {})
   141|export const previewBudgetCategory = (body: unknown) => apiPost<BudgetPreview>('/api/budget/categories/preview', body)
   142|export const confirmBudgetCategory = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/categories/confirm', body)
   143|export const previewBudgetCategoryCleanup = () => apiPost<Record<string, unknown> & { preview_id: string; summary: string; target_expense_categories: string[]; auto_mappings: Array<Record<string, unknown>>; review_required: Array<Record<string, unknown>>; empty_categories: Array<Record<string, unknown>>; payload: Record<string, unknown>; warnings: string[] }>('/api/budget/categories/cleanup/preview', {})
   144|export const confirmBudgetCategoryCleanup = (body: unknown) => apiPost<Record<string, unknown> & { audit_id: string }>('/api/budget/categories/cleanup/confirm', body)
   145|export const reorderBudgetCategories = (category_ids: string[], category_type: 'expense' | 'income' = 'expense') => apiPost<BudgetConfirm & { updated_count: number }>('/api/budget/categories/reorder', { category_ids, category_type })
   146|export const updateBudgetCategory = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/update`, body)
   147|export const archiveBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/archive`, {})
   148|export const previewBudgetTag = (body: unknown) => apiPost<BudgetPreview>('/api/budget/tags/preview', body)
   149|export const confirmBudgetTag = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/tags/confirm', body)
   150|export const archiveBudgetTag = (id: string) => apiPost<BudgetConfirm>(`/api/budget/tags/${id}/archive`, {})
   151|export const getBudgetSeedCandidates = () => apiGet<BudgetSeedCandidate[]>('/api/budget/seed-candidates')
   152|export const generateBudgetSeedCandidates = (body: unknown) => apiPost<BudgetSeedSummary>('/api/budget/seed-candidates/generate', body)
   153|export const previewBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/seed-candidates/${id}/preview`, body)
   154|export const confirmBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/confirm`, body)
   155|export const ignoreBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/ignore`, { note })
   156|export const reopenBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/reopen`, { note })
   157|export const setBudgetSeedCandidateStatus = (id: string, status: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/status`, { status, note })
   158|export const convertBudgetUncleanRange = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/convert-unclean`, body)
   159|export const previewClearBudgetCategoriesTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; categories: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/categories/preview-clear-takeover', {})
   160|export const confirmClearBudgetCategoriesTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/categories/confirm-clear-takeover', {})
   161|export const previewBudgetPlanTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; budget_plans: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/budget-plans/preview-takeover', {})
   162|export const confirmBudgetPlanTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/budget-plans/confirm-takeover', {})
   163|export const getBudgetTransactionCandidates = (status = '', tab = '', filters: Record<string, string> = {}) => {
   164|  const params = new URLSearchParams()
   165|  if (status) params.set('status', status)
   166|  if (tab) params.set('tab', tab)
   167|  Object.entries(filters).forEach(([k, v]) => { if (v) params.set(k, v) })
   168|  const qs = params.toString()
   169|  return apiGet<BudgetTransactionCandidate[]>(`/api/budget/transaction-candidates${qs ? `?${qs}` : ''}`)
   170|}
   171|export const getBudgetTransactionCandidateSummary = (budget_year = '2026') => apiGet<BudgetReviewSummary>(`/api/budget/transaction-candidates/summary?budget_year=${budget_year}`)
   172|export const generateBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, number>>('/api/budget/transaction-candidates/generate', body)
   173|export const previewBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/transaction-candidates/${id}/preview`, body)
   174|export const confirmBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/confirm`, body)
   175|export const ignoreBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/ignore`, { note })
   176|export const reopenBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/reopen`, { note })
   177|export const updateBudgetTransactionCandidateCategory = (id: string, category_id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/category`, { category_id, note })
   178|export const bulkUpdateBudgetTransactionCandidateCategory = (candidate_ids: string[], category_id: string, note = '') => apiPost<{ updated_count: number; skipped_count: number }>('/api/budget/transaction-candidates/bulk-category', { candidate_ids, category_id, note })
   179|export const splitBudgetTransactionCandidate = (id: string, splits: unknown[]) => apiPost<BudgetConfirm & { split_count: number }>(`/api/budget/transaction-candidates/${id}/split`, { splits })
   180|export const markBudgetTransactionCandidateTransfer = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/transfer`, { note })
   181|export const markBudgetTransactionCandidateType = (id: string, candidate_type: 'transfer' | 'investment_transfer' | 'credit_card_payment', note = '') => apiPost<BudgetConfirm & { classification: string }>(`/api/budget/transaction-candidates/${id}/type`, { candidate_type, note })
   182|export const getBudgetImportProfiles = () => apiGet<Array<Record<string, unknown>>>('/api/budget/import-profiles')
   183|export const dryRunBudgetImportProfile = (profile: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/import-profiles/${profile}/dry-run`, body)
   184|export const createBudgetImportCandidates = (profile: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/import-profiles/${profile}/candidates`, body)
   185|export const previewSafeBudgetTransactionCandidates = () => apiPost<{ preview_id: string; summary: string; count: number; groups: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/transaction-candidates/batch-confirm-safe/preview', {})
   186|export const batchConfirmSafeBudgetTransactionCandidates = (account_id: string, candidate_ids: string[] = []) => apiPost<{ confirmed_count: number; skipped_count: number }>('/api/budget/transaction-candidates/batch-confirm-safe', { account_id, candidate_ids })
   187|export const deleteEmptyBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/delete-empty`, {})
   188|export const previewBudgetCategoryMerges = () => apiPost<{ preview_id: string; summary: string; suggestions: Array<{ target_category_id: string; target_name: string; source_category_ids: string[]; source_names: string[] }>; warnings: string[] }>('/api/budget/categories/merge/preview', {})
   189|export const confirmBudgetCategoryMerge = (body: unknown) => apiPost<BudgetConfirm & { moved_transactions: number; moved_budget_plans: number; moved_children: number }>('/api/budget/categories/merge/confirm', body)
   190|export const previewBatchBudgetTransactionCandidates = (candidate_ids: string[], account_id: string, budget_year = '2026') => apiPost<{ preview_id: string; count: number; categories: Record<string, number>; sources?: Record<string, number>; possible_duplicates?: number; blockers?: Record<string, number>; conflicts: Array<Record<string, unknown>>; can_confirm?: boolean; requires_explicit_confirm: boolean }>('/api/budget/transaction-candidates/batch/preview', { candidate_ids, account_id, budget_year })
   191|export const batchBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/batch', body)
   192|export const confirmBudgetTransactionCandidateSplit = (id: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/transaction-candidates/${id}/split/confirm`, body)
   193|export const createReviewCategoryForCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm & { category_id: string }>(`/api/budget/transaction-candidates/${id}/category/create`, body)
   194|export const getBudgetRules = () => apiGet<BudgetRule[]>('/api/budget/rules')
   195|export const createBudgetRule = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/rules', body)
   196|export const updateBudgetRule = (id: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/update`, body)
   197|export const prioritizeBudgetRule = (id: string, priority: number) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/priority`, { priority })
   198|export const deactivateBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/deactivate`, {})
   199|export const testBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/test`, {})
   200|export const applyBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/apply`, {})
   201|export const getBudgetMerchants = () => apiGet<BudgetMerchant[]>('/api/budget/merchants')
   202|export const createBudgetMerchant = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/merchants', body)
   203|export const createBudgetMerchantAlias = (merchantId: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/aliases`, body)
   204|export const updateBudgetMerchantCategory = (merchantId: string, categoryId: string) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/category`, { category_id: categoryId })
   205|export const applyBudgetMerchant = (merchantId: string) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/apply`, {})
   206|export const mergeBudgetMerchants = (source_merchant_id: string, target_merchant_id: string) => apiPost<Record<string, unknown>>('/api/budget/merchants/merge', { source_merchant_id, target_merchant_id })
   207|export const detectBudgetDuplicateCandidates = () => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/detect-duplicates', {})
   208|export const reclassifyBankIncomeCandidates = (budget_year = '2026') => apiPost<Record<string, number>>('/api/budget/transaction-candidates/bank-income/reclassify', { budget_year })
   209|