     1|from __future__ import annotations
     2|
     3|from datetime import date
     4|from decimal import Decimal
     5|from sqlite3 import Connection
     6|
     7|from jarvis_finance.services.budget_common import row_to_dict
     8|from jarvis_finance.services.budget_transactions import list_budget_transactions
     9|
    10|
    11|def _current_month(conn: Connection) -> str:
    12|    tx = conn.execute("SELECT substr(MAX(transaction_date),1,7) AS month FROM budget_transactions").fetchone()
    13|    if tx and tx["month"]:
    14|        return tx["month"]
    15|    plan = conn.execute("SELECT MIN(plan_month) AS month FROM budget_plan_items WHERE is_active=1").fetchone()
    16|    if plan and plan["month"]:
    17|        return plan["month"]
    18|    return conn.execute("SELECT substr(date('now'),1,7)").fetchone()[0]
    19|
    20|
    21|def _amount(row) -> Decimal:
    22|    return Decimal(str(row["amount_chf"] or row["amount_original"] or "0"))
    23|
    24|
    25|def _monthly_plan_amount(plan) -> Decimal:
    26|    if plan["monthly_amount_chf"]:
    27|        return Decimal(str(plan["monthly_amount_chf"]))
    28|    if plan["annual_amount_chf"]:
    29|        return Decimal(str(plan["annual_amount_chf"])) / Decimal("12")
    30|    return Decimal("0")
    31|
    32|
    33|def _annual_plan_amount(plan) -> Decimal:
    34|    if plan["annual_amount_chf"]:
    35|        return Decimal(str(plan["annual_amount_chf"]))
    36|    if plan["monthly_amount_chf"]:
    37|        return Decimal(str(plan["monthly_amount_chf"])) * Decimal("12")
    38|    return Decimal("0")
    39|
    40|
    41|def _fmt(value: Decimal) -> str:
    42|    return format(value, "f")
    43|
    44|
    45|def get_monthly_summary(conn: Connection, month: str | None = None) -> dict:
    46|    selected_month = month or _current_month(conn)
    47|    rows = conn.execute("SELECT * FROM budget_transactions WHERE status='confirmed' AND substr(transaction_date,1,7)=?", (selected_month,)).fetchall()
    48|    income = Decimal("0")
    49|    expense = Decimal("0")
    50|    for row in rows:
    51|        amount = _amount(row)
    52|        if row["transaction_type"] in {"income", "refund"}:
    53|            income += amount
    54|        elif row["transaction_type"] in {"expense", "fee"}:
    55|            expense += amount
    56|    return {"month": selected_month, "income_chf": _fmt(income), "expense_chf": _fmt(expense), "net_cashflow_chf": _fmt(income - expense), "transaction_count": len(rows)}
    57|
    58|
    59|def get_category_summary(conn: Connection, month: str | None = None) -> list[dict]:
    60|    selected_month = month or _current_month(conn)
    61|    rows = conn.execute(
    62|        """
    63|        SELECT COALESCE(c.category_id, 'uncategorized') AS summary_category_id,
    64|               COALESCE(c.name, 'Unkategorisiert') AS summary_category_name,
    65|               t.transaction_type,
    66|               t.amount_chf,
    67|               t.amount_original,
    68|               COUNT(*) AS count
    69|        FROM budget_transactions t
    70|        LEFT JOIN budget_categories c ON c.category_id=t.category_id
    71|        WHERE t.status='confirmed' AND substr(t.transaction_date,1,7)=?
    72|          AND t.transaction_type IN ('income','expense','refund','fee')
    73|        GROUP BY COALESCE(c.category_id, 'uncategorized'), COALESCE(c.name, 'Unkategorisiert'), t.transaction_type, t.amount_chf, t.amount_original
    74|        """,
    75|        (selected_month,),
    76|    ).fetchall()
    77|    totals: dict[str, dict] = {}
    78|    for row in rows:
    79|        item = totals.setdefault(row["summary_category_id"], {"category_id": row["summary_category_id"], "category_name": row["summary_category_name"], "amount_chf": Decimal("0"), "transaction_count": 0})
    80|        item["amount_chf"] += Decimal(str(row["amount_chf"] or row["amount_original"] or "0"))
    81|        item["transaction_count"] += int(row["count"])
    82|    result = list(totals.values())
    83|    result.sort(key=lambda x: x["amount_chf"], reverse=True)
    84|    return [{**r, "amount_chf": _fmt(r["amount_chf"])} for r in result]
    85|
    86|
    87|def _months_denominator(year: str, months_with_bookings: int) -> int:
    88|    today = date.today()
    89|    if str(today.year) == year:
    90|        elapsed = today.month
    91|    else:
    92|        elapsed = 12
    93|    return max(1, min(12, max(months_with_bookings, elapsed)))
    94|
    95|
    96|def _status(category_type: str, budget_year: Decimal, forecast_year: Decimal, actual_year: Decimal, months_with_bookings: int) -> str:
    97|    if actual_year == 0 or months_with_bookings == 0 or budget_year <= 0:
    98|        return "Noch keine Daten"
    99|    ratio = forecast_year / budget_year
   100|    if category_type == "income":
   101|        if ratio >= Decimal("1"):
   102|            return "Grün"
   103|        if ratio >= Decimal("0.90"):
   104|            return "Gelb"
   105|        return "Rot"
   106|    if ratio > Decimal("1.15"):
   107|        return "Kritisch"
   108|    if ratio > Decimal("1"):
   109|        return "Rot"
   110|    if ratio >= Decimal("0.90"):
   111|        return "Gelb"
   112|    return "Grün"
   113|
   114|
   115|def get_budget_status_by_category(conn: Connection, *, year: str = "2026") -> list[dict]:
   116|    plan_rows = conn.execute(
   117|        """
   118|        SELECT p.*, c.name AS category_name, c.category_type, c.sort_order
   119|        FROM budget_plan_items p
   120|        JOIN budget_categories c ON c.category_id=p.category_id
   121|        WHERE p.is_active=1 AND c.is_active=1 AND substr(p.plan_month,1,4)=?
   122|        ORDER BY c.sort_order, c.name, p.name
   123|        """,
   124|        (year,),
   125|    ).fetchall()
   126|    actual_rows = conn.execute(
   127|        """
   128|        SELECT category_id, SUM(CAST(COALESCE(amount_chf, amount_original, '0') AS REAL)) AS amount,
   129|               COUNT(DISTINCT substr(transaction_date,1,7)) AS months
   130|        FROM budget_transactions
   131|        WHERE status='confirmed' AND substr(transaction_date,1,4)=?
   132|          AND transaction_type IN ('income','refund','expense','fee')
   133|        GROUP BY category_id
   134|        """,
   135|        (year,),
   136|    ).fetchall()
   137|    monthly_rows = conn.execute(
   138|        """
   139|        SELECT category_id, substr(transaction_date,6,2) AS month_no,
   140|               SUM(CAST(COALESCE(amount_chf, amount_original, '0') AS REAL)) AS amount
   141|        FROM budget_transactions
   142|        WHERE status='confirmed' AND substr(transaction_date,1,4)=?
   143|          AND transaction_type IN ('income','refund','expense','fee')
   144|        GROUP BY category_id, substr(transaction_date,6,2)
   145|        """,
   146|        (year,),
   147|    ).fetchall()
   148|    monthly_actuals: dict[str, dict[str, Decimal]] = {}
   149|    for r in monthly_rows:
   150|        if r["category_id"]:
   151|            monthly_actuals.setdefault(r["category_id"], {})[str(r["month_no"])] = Decimal(str(r["amount"] or 0))
   152|    actual = {r["category_id"]: (Decimal(str(r["amount"] or 0)), int(r["months"] or 0)) for r in actual_rows}
   153|    grouped: dict[str, dict] = {}
   154|    for actual_row in actual_rows:
   155|        category_id = actual_row["category_id"]
   156|        if not category_id:
   157|            continue
   158|        category = conn.execute("SELECT category_id, name, category_type FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone()
   159|        if not category:
   160|            continue
   161|        grouped[category_id] = {
   162|            "category_id": category_id,
   163|            "category": category["name"],
   164|            "category_type": category["category_type"],
   165|            "budget_month": Decimal("0"),
   166|            "budget_year": Decimal("0"),
   167|            "plans": [],
   168|        }
   169|    for plan in plan_rows:
   170|        item = grouped.setdefault(plan["category_id"], {
   171|            "category_id": plan["category_id"],
   172|            "category": plan["category_name"],
   173|            "category_type": plan["category_type"],
   174|            "budget_month": Decimal("0"),
   175|            "budget_year": Decimal("0"),
   176|            "plans": [],
   177|        })
   178|        bm = _monthly_plan_amount(plan)
   179|        by = _annual_plan_amount(plan)
   180|        item["budget_month"] += bm
   181|        item["budget_year"] += by
   182|        item["plans"].append(plan["name"])
   183|    results: list[dict] = []
   184|    for category_id, item in grouped.items():
   185|        actual_year, months = actual.get(category_id, (Decimal("0"), 0))
   186|        denom = _months_denominator(year, months)
   187|        actual_avg = actual_year / Decimal(denom)
   188|        forecast_year = actual_avg * Decimal("12") if actual_year else Decimal("0")
   189|        forecast_month = forecast_year / Decimal("12") if forecast_year else Decimal("0")
   190|        deviation = forecast_year - item["budget_year"]
   191|        results.append({
   192|            "category_id": category_id,
   193|            "category": item["category"],
   194|            "category_type": item["category_type"],
   195|            "budget_month": _fmt(item["budget_month"]),
   196|            "budget_year": _fmt(item["budget_year"]),
   197|            "actual_year_to_date": _fmt(actual_year),
   198|            "monthly_actuals": {f"{m:02d}": _fmt(monthly_actuals.get(category_id, {}).get(f"{m:02d}", Decimal("0"))) for m in range(1, 13)},
   199|            "actual_avg_month": _fmt(actual_avg),
   200|            "forecast_year": _fmt(forecast_year),
   201|            "forecast_avg_month": _fmt(forecast_month),
   202|            "deviation_year": _fmt(deviation),
   203|            "deviation_month": _fmt(deviation / Decimal("12")),
   204|            "status": _status(item["category_type"], item["budget_year"], forecast_year, actual_year, months),
   205|            "months_with_bookings": months,
   206|            "plan_names": item["plans"],
   207|        })
   208|    return results
   209|
   210|
   211|def get_budget_charts_2026(conn: Connection, *, year: str = "2026") -> dict:
   212|    cash_rows = conn.execute(
   213|        """
   214|        SELECT substr(transaction_date,1,7) AS month,
   215|               SUM(CASE WHEN transaction_type IN ('income','refund') THEN CAST(COALESCE(amount_chf, amount_original, '0') AS REAL) ELSE 0 END) AS income,
   216|               SUM(CASE WHEN transaction_type IN ('expense','fee') THEN CAST(COALESCE(amount_chf, amount_original, '0') AS REAL) ELSE 0 END) AS expense
   217|        FROM budget_transactions
   218|        WHERE status='confirmed' AND substr(transaction_date,1,4)=?
   219|        GROUP BY substr(transaction_date,1,7)
   220|        ORDER BY month
   221|        """,
   222|        (year,),
   223|    ).fetchall()
   224|    cashflow = []
   225|    for r in cash_rows:
   226|        income = Decimal(str(r["income"] or 0)); expense = Decimal(str(r["expense"] or 0))
   227|        cashflow.append({"year": year, "month": r["month"], "income_chf": _fmt(income), "expense_chf": _fmt(expense), "net_cashflow_chf": _fmt(income - expense)})
   228|    status_rows = get_budget_status_by_category(conn, year=year)
   229|    budget_vs_actual = [
   230|        {"year": year, "category_id": r["category_id"], "category": r["category"], "category_type": r["category_type"], "budget_year": r["budget_year"], "actual_year_to_date": r["actual_year_to_date"], "forecast_year": r["forecast_year"], "status": r["status"]}
   231|        for r in status_rows
   232|    ]
   233|    overruns = [r for r in budget_vs_actual if r["status"] in {"Rot", "Kritisch"}]
   234|    return {
   235|        "year": year,
   236|        "empty_state": not cashflow,
   237|        "cashflow_by_month": cashflow,
   238|        "budget_vs_actual_by_category": budget_vs_actual,
   239|        "forecast_overruns": overruns,
   240|    }
   241|
   242|
   243|def get_income_status(conn: Connection, *, year: str = "2026") -> dict:
   244|    rows = [r for r in get_budget_status_by_category(conn, year=year) if r["category_type"] == "income"]
   245|    def s(key: str) -> Decimal:
   246|        return sum((Decimal(str(r[key])) for r in rows), Decimal("0"))
   247|    budget_year = s("budget_year")
   248|    forecast_year = s("forecast_year")
   249|    actual_year = s("actual_year_to_date")
   250|    return {
   251|        "year": year,
   252|        "planned_income_month": _fmt(s("budget_month")),
   253|        "planned_income_year": _fmt(budget_year),
   254|        "actual_income_year_to_date": _fmt(actual_year),
   255|        "forecast_income_year": _fmt(forecast_year),
   256|        "deviation_year": _fmt(forecast_year - budget_year),
   257|        "status": _status("income", budget_year, forecast_year, actual_year, max((int(r["months_with_bookings"]) for r in rows), default=0)),
   258|        "categories": rows,
   259|    }
   260|
   261|

Use offset=261 to continue reading (showing 1-260 of 311 lines)


   560|    if status:
   561|        clauses.append("status=?"); params.append(status)
   562|    elif not include_reference and not tab:
   563|        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','superseded','reference_2025','archived_reference')")
   564|    if tab:
   565|        mapping = {
   566|            "auto": "requires_review=0 AND status IN ('pending','auto_categorized')",
   567|            "review": "requires_review=1 AND status IN ('needs_review','pending','transfer_candidate','duplicate','duplicate_candidate')",
   568|            "subscriptions": "classification='subscription_auto'",
   569|            "galaxus": "classification='galaxus_review'",
   570|            "migros_over_50": "classification='migros_review_over_threshold'",
   571|            "health": "classification='health_medical'",
   572|            "auto_transport": "classification='auto_transport'",
   573|            "shopping": "classification IN ('shopping','galaxus_review')",
   574|            "pets": "classification='pets'",
   575|            "unclear": "classification IN ('admin_other','unclear','legacy_csv_seed','card_uncategorized')",
   576|            "covered_by_migros": "status='covered_by_migros'",
   577|            "duplicates": "status IN ('duplicate','duplicate_candidate')",
   578|            "transfers": "classification='transfer_candidate' OR status='transfer_candidate'",
   579|            "bank": "source_type='bank_csv'",
   580|            "income": "classification='income_candidate'",
   581|            "ignored": "status IN ('ignored','covered_by_migros','superseded','reference_2025','archived_reference')",
   582|        }
   583|        if tab in mapping:
   584|            clauses.append(mapping[tab])
   585|    if source_type:
   586|        clauses.append("source_type=?"); params.append(source_type)
   587|    if category_id:
   588|        clauses.append("proposed_category_id=?"); params.append(category_id)
   589|    if merchant:
   590|        clauses.append("lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"); params.append(f"%{merchant.lower()}%")
   591|    if rule:
   592|        clauses.append("lower(COALESCE(rule_id,'') || ' ' || COALESCE(rule_name,'') || ' ' || COALESCE(review_reason,'')) LIKE ?"); params.append(f"%{rule.lower()}%")
   593|    if min_confidence:
   594|        clauses.append("CAST(COALESCE(confidence,'0') AS REAL) >= CAST(? AS REAL)"); params.append(min_confidence)
   595|    if date_from:
   596|        clauses.append("transaction_date>=?"); params.append(date_from)
   597|    if date_to:
   598|        clauses.append("transaction_date<=?"); params.append(date_to)
   599|    if amount_min:
   600|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) >= CAST(? AS REAL)"); params.append(amount_min)
   601|    if amount_max:
   602|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) <= CAST(? AS REAL)"); params.append(amount_max)
   603|    where = "WHERE " + " AND ".join(clauses) if clauses else ""
   604|    sort_map = {"date": "transaction_date", "merchant": "lower(COALESCE(merchant, description))", "category": "proposed_category_name", "confidence": "CAST(COALESCE(confidence,'0') AS REAL)", "source": "source_type", "status": "status", "created_at": "created_at"}
   605|    order_col = sort_map.get(sort_by, "created_at")
   606|    direction = "ASC" if sort_dir.lower() == "asc" else "DESC"
   607|    rows = conn.execute(f"SELECT * FROM budget_transaction_candidates {where} ORDER BY {order_col} {direction}, created_at DESC LIMIT 500", params).fetchall()
   608|    return [row_to_dict(r) | {"requires_review": bool(r["requires_review"])} for r in rows]
   609|
   610|
   611|def preview_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
   612|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   613|    if not row:
   614|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   615|    payload = payload or {}
   616|    cat_id = payload.get("category_id") or row["proposed_category_id"]
   617|    notes = row["notes"] or ""
   618|    tx_type = payload.get("transaction_type") or "expense"
   619|    try:
   620|        meta = json.loads(notes) if notes and notes.startswith("{") else {}
   621|        tx_type = payload.get("transaction_type") or meta.get("transaction_type") or tx_type
   622|    except json.JSONDecodeError:
   623|        pass
   624|    if not cat_id and tx_type in {"expense", "income"}:
   625|        raise HTTPException(status_code=422, detail="category required before confirm")
   626|    if cat_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat_id,)).fetchone():
   627|        raise HTTPException(status_code=422, detail="active category required before confirm")
   628|    if not payload.get("account_id"):
   629|        raise HTTPException(status_code=422, detail="account_id required before confirm")
   630|    if not row["transaction_date"]:
   631|        raise HTTPException(status_code=422, detail="transaction date required before confirm")
   632|    if not row["amount_original"]:
   633|        raise HTTPException(status_code=422, detail="amount required before confirm")
   634|    if not row["currency_original"]:
   635|        raise HTTPException(status_code=422, detail="currency required before confirm")
   636|    return {
   637|        "preview_id": new_id("preview"),
   638|        "summary": "Buchungskandidat übernehmen",
   639|        "warnings": ["Möglicher Duplikat-Treffer"] if row["duplicate_of_transaction_id"] else ([] if cat_id or tx_type == "transfer" else ["Kategorie fehlt"]),
   640|        "payload": {
   641|            "account_id": payload.get("account_id"),
   642|            "transaction_type": tx_type,
   643|            "transaction_date": payload.get("transaction_date") or row["transaction_date"],
   644|            "description": payload.get("description") or row["description"],
   645|            "payee": payload.get("payee") or row["merchant"],
   646|            "amount_original": row["amount_original"],
   647|            "currency_original": row["currency_original"],
   648|            "category_id": cat_id,
   649|            "source_type": "import_candidate",
   650|            "source_candidate_id": candidate_id,
   651|            "notes": f"Confirmed from transaction candidate {candidate_id}",
   652|        },
   653|    }
   654|
   655|
   656|def confirm_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   657|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   658|    if not row:
   659|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   660|    if row["status"] == "confirmed":
   661|        raise HTTPException(status_code=409, detail="candidate already confirmed")
   662|    if row["status"] in {"covered_by_migros", "superseded", "ignored", "reference_2025", "archived_reference"}:
   663|        raise HTTPException(status_code=409, detail="candidate is not confirmable")
   664|    if str(row["transaction_date"] or "")[:4] != str(payload.get("budget_year") or "2026"):
   665|        raise HTTPException(status_code=409, detail="candidate outside active budget year")
   666|    preview = preview_transaction_candidate(conn, candidate_id, payload)
   667|    result = confirm_budget_transaction(conn, preview["payload"])
   668|    ts = now()
   669|    conn.execute("UPDATE budget_transaction_candidates SET status='confirmed', confirmed_transaction_id=?, confirmed_at=?, confirmed_by='user', updated_at=? WHERE transaction_candidate_id=?", (result["entity_id"], ts, ts, candidate_id))
   670|    record_audit_event(conn, source="vue_dashboard", action="budget_transaction_created", entity_type="budget_transaction", entity_id=result["entity_id"], new_values={"source_candidate_id": candidate_id}, created_by="user")
   671|    audit_id = record_audit_event(conn, source="vue_dashboard", action="candidate_confirmed", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"]}, new_values={"status": "confirmed", "confirmed_transaction_id": result["entity_id"], "category_id": preview["payload"].get("category_id")}, created_by="user")
   672|    conn.commit()
   673|    return {"status": "confirmed", "entity_id": result["entity_id"], "audit_id": audit_id, "message": "Buchungskandidat übernommen"}
   674|
   675|
   676|def ignore_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   677|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   678|    if not row:
   679|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   680|    ts = now()
   681|    conn.execute("UPDATE budget_transaction_candidates SET status='ignored', notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (note, ts, candidate_id))
   682|    audit_id = record_audit_event(conn, source="vue_dashboard", action="ignore", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"]}, new_values={"status": "ignored"}, created_by="user")
   683|    conn.commit()
   684|    return {"status": "ignored", "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat ignoriert"}
   685|
   686|
   687|def reopen_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   688|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   689|    if not row:
   690|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   691|    if row["status"] == "confirmed":
   692|        raise HTTPException(status_code=409, detail="confirmed candidates cannot be reopened")
   693|    new_status = "needs_review" if row["requires_review"] else "pending"
   694|    ts = now()
   695|    conn.execute("UPDATE budget_transaction_candidates SET status=?, review_reason=COALESCE(review_reason, 'user_reopened'), notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (new_status, note, ts, candidate_id))
   696|    audit_id = record_audit_event(conn, source="vue_dashboard", action="reopen", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"]}, new_values={"status": new_status}, user_text_note=note, created_by="user")
   697|    conn.commit()
   698|    return {"status": new_status, "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat wieder geöffnet"}
   699|
   700|
   701|def update_transaction_candidate_category(conn: Connection, candidate_id: str, category_id: str, note: str | None = None) -> dict[str, Any]:
   702|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   703|    if not row:
   704|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   705|    if not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone():
   706|        raise HTTPException(status_code=422, detail="active category not found")
   707|    cname = _category_name(conn, category_id)
   708|    ts = now()
   709|    conn.execute("UPDATE budget_transaction_candidates SET proposed_category_id=?, proposed_category_name=?, requires_review=0, status='pending', review_reason='user_category_override', updated_at=? WHERE transaction_candidate_id=?", (category_id, cname, ts, candidate_id))
   710|    audit_id = record_audit_event(conn, source="vue_dashboard", action="update_category", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values=row_to_dict(row), new_values={"category_id": category_id, "category_name": cname}, user_text_note=note, created_by="user")
   711|    conn.commit()
   712|    return {"status": "updated", "entity_id": candidate_id, "audit_id": audit_id}
   713|
   714|
   715|def create_candidate_split(conn: Connection, candidate_id: str, splits: list[dict[str, Any]]) -> dict[str, Any]:
   716|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   717|    if not row:
   718|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   719|    if not splits:
   720|        raise HTTPException(status_code=422, detail="splits required")
   721|    ts = now()
   722|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
   723|    for split in splits:
   724|        amount = _decimal_text(split.get("amount_original"))
   725|        if not amount:
   726|            raise HTTPException(status_code=422, detail="split amount required")
   727|        conn.execute("INSERT INTO budget_candidate_splits(split_id, transaction_candidate_id, category_id, amount_original, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", (new_id("bcsplit"), candidate_id, split.get("category_id"), amount, split.get("notes"), ts, ts))
   728|    conn.execute("UPDATE budget_transaction_candidates SET requires_review=1, status='needs_review', review_reason='split_pending_confirm', updated_at=? WHERE transaction_candidate_id=?", (ts, candidate_id))
   729|    audit_id = record_audit_event(conn, source="vue_dashboard", action="split", entity_type="budget_transaction_candidate_split", entity_id=candidate_id, new_values={"split_count": len(splits)}, created_by="user")
   730|    conn.commit()
   731|    return {"status": "split_created", "entity_id": candidate_id, "split_count": len(splits), "audit_id": audit_id}
   732|
   733|
   734|def mark_candidate_transfer(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   735|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   736|    if not row:
   737|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   738|    ts = now()
   739|    conn.execute("UPDATE budget_transaction_candidates SET status='transfer_candidate', classification='transfer_candidate', requires_review=1, review_reason='user_marked_transfer', notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (note, ts, candidate_id))
   740|    audit_id = record_audit_event(conn, source="vue_dashboard", action="mark_transfer", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"]}, new_values={"status": "transfer_candidate"}, user_text_note=note, created_by="user")
   741|    conn.commit()
   742|    return {"status": "transfer_candidate", "entity_id": candidate_id, "audit_id": audit_id}
   743|
   744|
   745|def bulk_update_transaction_candidate_category(conn: Connection, *, candidate_ids: list[str], category_id: str, note: str | None = None) -> dict[str, int]:
   746|    updated = skipped = 0
   747|    for cid in candidate_ids:
   748|        row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   749|        if not row or row["status"] == "confirmed":
   750|            skipped += 1
   751|            continue
   752|        update_transaction_candidate_category(conn, cid, category_id, note or "Bulk category assignment")
   753|        updated += 1
   754|    return {"updated_count": updated, "skipped_count": skipped}
   755|
   756|
   757|def _safe_auto_candidate_where() -> str:
   758|    return """
   759|        status='pending'
   760|        AND COALESCE(requires_review, 0)=0
   761|        AND duplicate_of_transaction_id IS NULL
   762|        AND COALESCE(covered_by_source, '')=''
   763|        AND (
   764|            classification='subscription_auto'
   765|            OR (classification='migros_auto_under_threshold' AND CAST(COALESCE(amount_original, '999999') AS REAL) < 50)
   766|            OR classification='auto_transport'
   767|        )
   768|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%galaxus%'
   769|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%digitec%'
   770|    """
   771|
   772|
   773|def preview_safe_auto_candidates(conn: Connection) -> dict[str, Any]:
   774|    rows = conn.execute(
   775|        f"""
   776|        SELECT classification, COUNT(*) AS count
   777|        FROM budget_transaction_candidates
   778|        WHERE {_safe_auto_candidate_where()}
   779|        GROUP BY classification
   780|        ORDER BY classification
   781|        """
   782|    ).fetchall()
   783|    total = sum(int(r["count"] or 0) for r in rows)
   784|    return {
   785|        "preview_id": new_id("preview"),
   786|        "summary": f"{total} sichere Auto-Kandidaten bereit für Confirm",
   787|        "count": total,
   788|        "groups": [row_to_dict(r) for r in rows],
   789|        "warnings": ["Preview only: Confirm benötigt Zielkonto und bucht nur sichere Kandidaten."],
   790|    }
   791|
   792|
   793|def batch_confirm_safe_candidates(conn: Connection, *, account_id: str, candidate_ids: list[str] | None = None) -> dict[str, int]:
   794|    if not account_id:
   795|        raise HTTPException(status_code=422, detail="account_id required")
   796|    allowed_rows = conn.execute(f"SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE {_safe_auto_candidate_where()}").fetchall()
   797|    allowed_ids = {str(r["transaction_candidate_id"]) for r in allowed_rows}
   798|    requested_ids = set(candidate_ids or allowed_ids)
   799|    confirmed = skipped = 0
   800|    for cid in sorted(requested_ids):
   801|        if cid not in allowed_ids:
   802|            skipped += 1
   803|            continue
   804|        confirm_transaction_candidate(conn, cid, {"account_id": account_id})
   805|        confirmed += 1
   806|    record_audit_event(conn, source="vue_dashboard", action="batch_confirm_safe_candidates", entity_type="budget_transaction_candidate", entity_id="batch", new_values={"confirmed_count": confirmed, "skipped_count": skipped}, created_by="user")
   807|    conn.commit()
   808|    return {"confirmed_count": confirmed, "skipped_count": skipped}
   809|
   810|
   811|def review_candidate_summary(conn: Connection) -> dict[str, int]:
   812|    rows = conn.execute("SELECT status, classification, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','superseded')").fetchall()
   813|    def count(pred):
   814|        return sum(1 for r in rows if pred(r))
   815|    return {
   816|        "open_total": count(lambda r: r["status"] != "covered_by_migros"),
   817|        "auto_categorized": count(lambda r: r["status"] in {"pending", "auto_categorized"} and not bool(r["requires_review"])),
   818|        "review_needed": count(lambda r: bool(r["requires_review"])),
   819|        "galaxus_digitec": count(lambda r: r["classification"] == "galaxus_review"),
   820|        "migros_over_50": count(lambda r: r["classification"] == "migros_review_over_threshold"),
   821|        "covered_by_migros": count(lambda r: r["status"] == "covered_by_migros"),
   822|        "unclear": count(lambda r: r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
   823|        "possible_duplicates": count(lambda r: r["status"] in {"duplicate", "duplicate_candidate"} or bool(r["duplicate_of_transaction_id"])),
   824|    }
   825|
   826|
   827|def preview_batch_confirm_candidates(conn: Connection, *, candidate_ids: list[str], account_id: str, budget_year: str = "2026") -> dict[str, Any]:
   828|    rows = []
   829|    conflicts = []
   830|    categories: dict[str, int] = {}
   831|    for cid in candidate_ids:
   832|        row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   833|        if not row:
   834|            conflicts.append({"candidate_id": cid, "reason": "missing"}); continue
   835|        if row["status"] in {"confirmed", "ignored", "covered_by_migros", "superseded", "reference_2025", "archived_reference"}:
   836|            conflicts.append({"candidate_id": cid, "reason": f"not_confirmable:{row['status']}"}); continue
   837|        if str(row["transaction_date"] or "")[:4] != str(budget_year):
   838|            conflicts.append({"candidate_id": cid, "reason": "outside_active_budget_year"}); continue
   839|        try:
   840|            preview_transaction_candidate(conn, cid, {"account_id": account_id, "category_id": row["proposed_category_id"], "budget_year": budget_year})
   841|        except Exception as exc:
   842|            conflicts.append({"candidate_id": cid, "reason": str(exc)}); continue
   843|        rows.append(row)
   844|        categories[str(row["proposed_category_name"] or "Review nötig")] = categories.get(str(row["proposed_category_name"] or "Review nötig"), 0) + 1
   845|    if not rows and conflicts:
   846|        raise HTTPException(status_code=409, detail={"message": "no confirmable candidates", "conflicts": conflicts})
   847|    preview_id = new_id("batchprev")
   848|    record_audit_event(conn, source="vue_dashboard", action="batch_confirm_candidates_preview", entity_type="budget_transaction_candidate", entity_id=preview_id, new_values={"count": len(rows), "conflicts": len(conflicts), "categories": categories}, created_by="user")
   849|    conn.commit()
   850|    return {"preview_id": preview_id, "count": len(rows), "categories": categories, "conflicts": conflicts, "requires_explicit_confirm": True}
   851|
   852|
   853|def batch_mark_candidates(conn: Connection, *, candidate_ids: list[str], action: str, account_id: str | None = None, category_id: str | None = None, preview_id: str | None = None, note: str | None = None, budget_year: str = "2026") -> dict[str, Any]:
   854|    counts: dict[str, Any] = {"updated_count": 0, "confirmed_count": 0, "ignored_count": 0, "reopened_count": 0, "transfer_count": 0, "covered_by_migros_count": 0, "skipped_count": 0, "errors": []}
   855|    if action == "confirm" and not preview_id:
   856|        raise HTTPException(status_code=422, detail="batch confirm requires preview_id")
   857|    for cid in candidate_ids:
   858|        try:
   859|            row_for_year = conn.execute("SELECT transaction_date FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   860|            if not row_for_year:
   861|                raise HTTPException(status_code=404, detail="candidate not found")
   862|            if str(row_for_year["transaction_date"] or "")[:4] != str(budget_year):
   863|                raise HTTPException(status_code=409, detail="candidate outside active budget year")
   864|            if action == "category":
   865|                if not category_id: raise HTTPException(status_code=422, detail="category_id required")
   866|                update_transaction_candidate_category(conn, cid, category_id, note or "Batch category assignment"); counts["updated_count"] += 1
   867|            elif action == "confirm":
   868|                confirm_transaction_candidate(conn, cid, {"account_id": account_id, "budget_year": budget_year}); counts["confirmed_count"] += 1
   869|            elif action == "ignore":
   870|                ignore_transaction_candidate(conn, cid, note or "Batch ignore"); counts["ignored_count"] += 1
   871|            elif action == "reopen":
   872|                reopen_transaction_candidate(conn, cid, note or "Batch reopen"); counts["reopened_count"] += 1
   873|            elif action == "transfer":
   874|                mark_candidate_transfer(conn, cid, note or "Batch transfer"); counts["transfer_count"] += 1
   875|            elif action == "covered_by_migros":
   876|                ts = now(); row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   877|                if not row: raise HTTPException(status_code=404, detail="candidate not found")
   878|                conn.execute("UPDATE budget_transaction_candidates SET status='covered_by_migros', classification='covered_by_migros', covered_by_source='migros_csv', requires_review=0, review_reason='user_batch_marked_covered_by_migros', updated_at=? WHERE transaction_candidate_id=?", (ts, cid))
   879|                record_audit_event(conn, source="vue_dashboard", action="mark_covered_by_migros", entity_type="budget_transaction_candidate", entity_id=cid, old_values={"status": row["status"]}, new_values={"status": "covered_by_migros"}, created_by="user")
   880|                counts["covered_by_migros_count"] += 1
   881|            else:
   882|                raise HTTPException(status_code=422, detail="invalid batch action")
   883|        except Exception as exc:
   884|            counts["skipped_count"] += 1
   885|            detail = getattr(exc, "detail", str(exc))
   886|            counts["errors"].append({"candidate_id": cid, "error": str(detail)})
   887|    audit_id = record_audit_event(conn, source="vue_dashboard", action="batch_confirm_candidates" if action == "confirm" else f"batch_{action}_candidates", entity_type="budget_transaction_candidate", entity_id=preview_id or "batch", new_values=counts | {"candidate_count": len(candidate_ids)}, created_by="user")
   888|    conn.commit()
   889|    return counts | {"audit_id": audit_id}
   890|
   891|
   892|def confirm_candidate_split(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   893|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   894|    if not row:
   895|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   896|    if row["status"] in {"confirmed", "ignored", "covered_by_migros", "superseded"}:
   897|        raise HTTPException(status_code=409, detail="candidate is not splittable")
   898|    splits = payload.get("splits") or []
   899|    if not payload.get("account_id"):
   900|        raise HTTPException(status_code=422, detail="account_id required")
   901|    if not splits:
   902|        raise HTTPException(status_code=422, detail="splits required")
   903|    total = sum(Decimal(_decimal_text(s.get("amount_original")) or "0") for s in splits)
   904|    candidate_amount = Decimal(str(row["amount_original"] or "0"))
   905|    if total != candidate_amount:
   906|        raise HTTPException(status_code=422, detail="split sum must match candidate amount")
   907|    group_id = new_id("splitgrp")
   908|    ts = now()
   909|    created_ids = []
   910|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
   911|    for idx, split in enumerate(splits, start=1):
   912|        amount = _decimal_text(split.get("amount_original"))
   913|        cat = split.get("category_id")
   914|        if not cat or not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat,)).fetchone():
   915|            raise HTTPException(status_code=422, detail="active split category required")
   916|        result = confirm_budget_transaction(conn, {
   917|            "account_id": payload["account_id"], "transaction_type": payload.get("transaction_type") or "expense", "transaction_date": row["transaction_date"],
   918|            "description": f"{row['description']} · Split {idx}", "amount_original": amount, "currency_original": row["currency_original"],
   919|            "category_id": cat, "source_type": "import_candidate", "source_candidate_id": candidate_id,
   920|            "notes": f"Split group {group_id}: {split.get('note') or ''}", "tag_names": [split.get("tag")] if split.get("tag") else [],
   921|        })
   922|        created_ids.append(result["entity_id"])
   923|        conn.execute("INSERT INTO budget_candidate_splits(split_id, transaction_candidate_id, category_id, amount_original, notes, tag_name, confirmed_transaction_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (new_id("bcsplit"), candidate_id, cat, amount, split.get("note"), split.get("tag"), result["entity_id"], ts, ts))
   924|    primary_confirmed_id = created_ids[0] if created_ids else group_id
   925|    conn.execute("UPDATE budget_transaction_candidates SET status='confirmed', confirmed_transaction_id=?, confirmed_at=?, confirmed_by='user', updated_at=? WHERE transaction_candidate_id=?", (primary_confirmed_id, ts, ts, candidate_id))
   926|    audit_id = record_audit_event(conn, source="vue_dashboard", action="candidate_split_confirmed", entity_type="budget_transaction_candidate", entity_id=candidate_id, new_values={"group_id": group_id, "primary_transaction_id": primary_confirmed_id, "transaction_ids": created_ids, "split_count": len(created_ids)}, created_by="user")
   927|    conn.commit()
   928|    return {"status": "confirmed", "group_id": primary_confirmed_id, "split_group_id": group_id, "created_transactions": len(created_ids), "transaction_ids": created_ids, "audit_id": audit_id}
   929|
   930|
   931|def create_review_category_for_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   932|    if not conn.execute("SELECT 1 FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone():
   933|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   934|    name = str(payload.get("name") or "").strip()
   935|    if not name:
   936|        raise HTTPException(status_code=422, detail="category name required")
   937|    ts = now(); category_id = new_id("bcat")
   938|    conn.execute("INSERT INTO budget_categories(category_id, parent_category_id, name, category_type, color, icon, is_active, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, NULL, 1, 999, ?, ?)", (category_id, payload.get("parent_category_id") or None, name, payload.get("category_type") or "expense", ts, ts))
   939|    update_transaction_candidate_category(conn, candidate_id, category_id, "Created from review")
   940|    audit_id = record_audit_event(conn, source="vue_dashboard", action="review_category_created", entity_type="budget_category", entity_id=category_id, new_values={"name": name, "candidate_id": candidate_id}, created_by="user")
   941|    conn.commit()
   942|    return {"status": "created", "category_id": category_id, "audit_id": audit_id}
   943|
   944|
   945|def create_budget_rule(conn: Connection, payload: dict[str, Any]) -> dict[str, Any]:
   946|    text = str(payload.get("merchant_contains") or "").strip()
   947|    if not text:
   948|        raise HTTPException(status_code=422, detail="merchant_contains required")
   949|    target_status = payload.get("target_status") or "review"
   950|    if target_status not in {"auto", "review"}:
   951|        raise HTTPException(status_code=422, detail="invalid target_status")
   952|    rule_id = new_id("brule"); ts = now()
   953|    conn.execute("INSERT INTO budget_review_rules(rule_id, merchant_contains, source_type, category_id, target_status, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?)", (rule_id, text, payload.get("source_type") or None, payload.get("category_id") or None, target_status, ts, ts))
   954|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_created", entity_type="budget_review_rule", entity_id=rule_id, new_values={"merchant_contains": text, "target_status": target_status}, created_by="user")
   955|    conn.commit()
   956|    return {"status": "created", "rule_id": rule_id, "audit_id": audit_id}
   957|
   958|
   959|def list_budget_rules(conn: Connection, *, include_inactive: bool = False) -> list[dict[str, Any]]:
   960|    where = "" if include_inactive else "WHERE r.is_active=1"
   961|    rows = conn.execute(f"SELECT r.*, c.name AS category_name FROM budget_review_rules r LEFT JOIN budget_categories c ON c.category_id=r.category_id {where} ORDER BY r.created_at DESC").fetchall()
   962|    return [row_to_dict(r) | {"is_active": bool(r["is_active"])} for r in rows]
   963|
   964|
   965|def _rule_match_rows(conn: Connection, rule_id: str):
   966|    rule = conn.execute("SELECT * FROM budget_review_rules WHERE rule_id=?", (rule_id,)).fetchone()
   967|    if not rule:
   968|        raise HTTPException(status_code=404, detail="rule not found")
   969|    clauses = ["status NOT IN ('confirmed','ignored','covered_by_migros','superseded')", "lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"]
   970|    params: list[Any] = [f"%{str(rule['merchant_contains']).lower()}%"]
   971|    if rule["source_type"]:
   972|        clauses.append("source_type=?"); params.append(rule["source_type"])
   973|    return rule, conn.execute("SELECT * FROM budget_transaction_candidates WHERE " + " AND ".join(clauses), params).fetchall()
   974|
   975|
   976|def test_budget_rule(conn: Connection, rule_id: str) -> dict[str, Any]:
   977|    rule, rows = _rule_match_rows(conn, rule_id)
   978|    return {"rule_id": rule_id, "match_count": len(rows), "would_set_category": rule["category_id"], "would_set_status": rule["target_status"]}
   979|
   980|

Use offset=980 to continue reading (showing 560-979 of 1033 lines)


Explanation:
**Evaluating budget functions**

I'm thinking I might want to use getBudgetCategoryTree later, but it’s not essential right now. I also need to consider adding the endpoint for `/categories/tree`. It could simplify things, but I feel like I need to ensure the function isn’t called until it’s necessary. Should I create a route for cleanup? Maybe not, but I wonder if the runtime could use the service. I might only need to change the migration version to apply the update.


   520|            if _matches_any(joined, TRANSFER_PATTERNS):
   521|                indices.add(left.idx); indices.add(right.idx)
   522|    return indices
   523|
   524|
   525|def seed_bank_transfer_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, income_category_id: str | None = None) -> dict[str, int]:
   526|    parsed = _bank_rows(rows)
   527|    transfer_indices = _find_transfer_indices(parsed)
   528|    income_name = _category_name(conn, income_category_id)
   529|    created = income = transfer = review = dup_count = 0
   530|    for br in parsed:
   531|        dup = _duplicate_of(conn, br.date, br.amount_abs, br.desc)
   532|        category_id = None; category_name = None; status = "needs_review"; needs_review = True; classification = "bank_review"; reason = "bank_payment_unclear"; tx_type = "expense" if br.amount_signed < 0 else "income"; conf = "0.50"
   533|        if br.idx in transfer_indices:
   534|            status = "transfer_candidate"; needs_review = True; classification = "transfer_candidate"; reason = "possible_internal_transfer"; tx_type = "transfer"; conf = "0.85"; transfer += 1
   535|        elif br.amount_signed > 0 and _matches_any(br.desc, INCOME_PATTERNS):
   536|            category_id = income_category_id; category_name = income_name; status = "pending"; needs_review = False; classification = "income_candidate"; reason = "income_pattern"; tx_type = "income"; conf = "0.85"; income += 1
   537|        elif dup:
   538|            status = "duplicate"; classification = "possible_duplicate"; reason = "matched_existing_transaction"; dup_count += 1
   539|        else:
   540|            review += 1
   541|        ts = now(); row_ref = f"R{br.idx}"
   542|        if _insert_candidate(conn, {
   543|            "transaction_candidate_id": "btxcand_" + _candidate_key(source_file_label, row_ref, br.date, br.amount_abs, br.desc),
   544|            "source_file_label": source_file_label, "source_row_or_range": row_ref, "source_type": "bank_csv",
   545|            "transaction_date": br.date, "description": br.desc[:300], "merchant": None, "amount_original": br.amount_abs, "currency_original": "CHF",
   546|            "proposed_category_id": category_id, "proposed_category_name": category_name, "duplicate_of_transaction_id": dup,
   547|            "confidence": conf, "requires_review": needs_review, "status": status, "notes": json.dumps({"transaction_type": tx_type, "reason": reason}, ensure_ascii=False),
   548|            "classification": classification, "review_reason": reason, "rule_id": classification, "source_priority": 80,
   549|            "account_source": br.account, "raw_fingerprint": _fingerprint([source_file_label, row_ref, br.date, br.amount_abs, br.desc, br.account]), "created_at": ts, "updated_at": ts,
   550|        }):
   551|            created += 1
   552|    conn.commit()
   553|    return {"candidate_count": created, "income_candidate_count": income, "transfer_candidate_count": transfer, "review_required_count": review, "possible_duplicate_count": dup_count}
   554|
   555|
   556|def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False) -> list[dict[str, Any]]:
   557|    clauses=[]; params=[]
   558|    if budget_year:
   559|        clauses.append("substr(transaction_date,1,4)=?"); params.append(budget_year)
   560|    if status:
   561|        clauses.append("status=?"); params.append(status)
   562|    elif not include_reference and not tab:
   563|        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','superseded','reference_2025','archived_reference')")
   564|    if tab:
   565|        mapping = {
   566|            "auto": "requires_review=0 AND status IN ('pending','auto_categorized')",
   567|            "review": "requires_review=1 AND status IN ('needs_review','pending','transfer_candidate','duplicate','duplicate_candidate')",
   568|            "subscriptions": "classification='subscription_auto'",
   569|            "galaxus": "classification='galaxus_review'",
   570|            "migros_over_50": "classification='migros_review_over_threshold'",
   571|            "health": "classification='health_medical'",
   572|            "auto_transport": "classification='auto_transport'",
   573|            "shopping": "classification IN ('shopping','galaxus_review')",
   574|            "pets": "classification='pets'",
   575|            "unclear": "classification IN ('admin_other','unclear','legacy_csv_seed','card_uncategorized')",
   576|            "covered_by_migros": "status='covered_by_migros'",
   577|            "duplicates": "status IN ('duplicate','duplicate_candidate')",
   578|            "transfers": "classification='transfer_candidate' OR status='transfer_candidate'",
   579|            "bank": "source_type='bank_csv'",
   580|            "income": "classification='income_candidate'",
   581|            "ignored": "status IN ('ignored','covered_by_migros','superseded','reference_2025','archived_reference')",
   582|        }
   583|        if tab in mapping:
   584|            clauses.append(mapping[tab])
   585|    if source_type:
   586|        clauses.append("source_type=?"); params.append(source_type)
   587|    if category_id:
   588|        clauses.append("proposed_category_id=?"); params.append(category_id)
   589|    if merchant:
   590|        clauses.append("lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"); params.append(f"%{merchant.lower()}%")
   591|    if rule:
   592|        clauses.append("lower(COALESCE(rule_id,'') || ' ' || COALESCE(rule_name,'') || ' ' || COALESCE(review_reason,'')) LIKE ?"); params.append(f"%{rule.lower()}%")
   593|    if min_confidence:
   594|        clauses.append("CAST(COALESCE(confidence,'0') AS REAL) >= CAST(? AS REAL)"); params.append(min_confidence)
   595|    if date_from:
   596|        clauses.append("transaction_date>=?"); params.append(date_from)
   597|    if date_to:
   598|        clauses.append("transaction_date<=?"); params.append(date_to)
   599|    if amount_min:
   600|

Use offset=600 to continue reading (showing 520-599 of 1033 lines)


   261|
   262|def get_budget_overview(conn: Connection, month: str | None = None) -> dict:
   263|    month = month or _current_month(conn)
   264|    year = month[:4]
   265|    monthly = get_monthly_summary(conn, month)
   266|    rows = conn.execute("SELECT * FROM budget_transactions WHERE status='confirmed' AND substr(transaction_date,1,7)=?", (month,)).fetchall()
   267|    review_count = conn.execute(
   268|        """
   269|        SELECT COUNT(*) FROM budget_transactions t
   270|        LEFT JOIN budget_transaction_tags tt ON tt.budget_transaction_id=t.budget_transaction_id
   271|        LEFT JOIN budget_tags tag ON tag.tag_id=tt.tag_id
   272|        WHERE t.status='confirmed' AND substr(t.transaction_date,1,7)=? AND (t.category_id IS NULL OR tag.name='Review' OR t.fx_status='missing')
   273|        """,
   274|        (month,),
   275|    ).fetchone()[0]
   276|    pending_seed_count = conn.execute("SELECT COUNT(*) FROM budget_seed_candidates WHERE status IN ('pending','needs_review','edited','accepted')").fetchone()[0]
   277|    pending_tx_candidates = conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates WHERE status IN ('pending','needs_review')").fetchone()[0]
   278|    uncategorized = sum(1 for r in rows if r["category_id"] is None and r["transaction_type"] in {"income", "expense", "refund", "fee"})
   279|    status_rows = get_budget_status_by_category(conn, year=year)
   280|    planned_income = sum((Decimal(r["budget_month"]) for r in status_rows if r["category_type"] == "income"), Decimal("0"))
   281|    planned_expense = sum((Decimal(r["budget_month"]) for r in status_rows if r["category_type"] == "expense"), Decimal("0"))
   282|    actual_income = Decimal(str(monthly["income_chf"]))
   283|    actual_expense = Decimal(str(monthly["expense_chf"]))
   284|    planned_net = planned_income - planned_expense
   285|    actual_net = actual_income - actual_expense
   286|    remaining_total = planned_expense - actual_expense
   287|    open_without_actuals = sum(1 for item in status_rows if item["status"] == "Noch keine Daten")
   288|    uncategorized_rows = [row_to_dict(r) for r in rows if r["category_id"] is None and r["transaction_type"] in {"income", "expense", "refund", "fee"}]
   289|    return {
   290|        "current_month": month,
   291|        **monthly,
   292|        "review_count": int(review_count),
   293|        "uncategorized_count": uncategorized,
   294|        "pending_seed_candidate_count": int(pending_seed_count),
   295|        "pending_transaction_candidate_count": int(pending_tx_candidates),
   296|        "planned_income_chf": _fmt(planned_income),
   297|        "planned_expense_chf": _fmt(planned_expense),
   298|        "planned_net_chf": _fmt(planned_net),
   299|        "actual_income_chf": _fmt(actual_income),
   300|        "actual_expense_chf": _fmt(actual_expense),
   301|        "actual_net_chf": _fmt(actual_net),
   302|        "open_categories_without_actuals": open_without_actuals,
   303|        "planned_budget_chf": _fmt(planned_expense),
   304|        "remaining_budget_chf": _fmt(remaining_total),
   305|        "budget_status": "no_plans" if not status_rows else ("no_actuals" if not rows else ("over_budget" if remaining_total < 0 else "ok")),
   306|        "actuals_label": "noch keine Buchungen" if not rows else "Buchungen vorhanden",
   307|        "budget_categories": status_rows,
   308|        "uncategorized_transactions": uncategorized_rows,
   309|        "top_categories": get_category_summary(conn, month)[:5],
   310|        "recent_transactions": list_budget_transactions(conn)[:8],
   311|    }
   312|


     1|from __future__ import annotations
     2|
     3|import hashlib
     4|from datetime import datetime, timezone
     5|from sqlite3 import Connection
     6|
     7|from .schema import INITIAL_SCHEMA_SQL
     8|
     9|MIGRATION_VERSION = 25
    10|MIGRATION_NAME = "025_budget_phase110_2026_core_cleanup"
    11|
    12|INSTRUMENT_OPTIONAL_COLUMNS = {
    13|    "position_category": "TEXT",
    14|    "ter": "TEXT",
    15|    "distribution_policy": "TEXT",
    16|    "index_name": "TEXT",
    17|    "fund_domicile": "TEXT",
    18|    "benchmark": "TEXT",
    19|    "is_currency_hedged": "INTEGER NOT NULL DEFAULT 0",
    20|    "hedged_to_currency": "TEXT",
    21|    "hedge_status": "TEXT NOT NULL DEFAULT 'unknown'",
    22|    "base_exposure_currency": "TEXT",
    23|    "trading_currency": "TEXT",
    24|    "instrument_status": "TEXT NOT NULL DEFAULT 'unknown'",
    25|    "valuation_policy": "TEXT NOT NULL DEFAULT 'live_price'",
    26|    "corporate_action_status": "TEXT NOT NULL DEFAULT 'not_checked'",
    27|    "split_or_corporate_action_review_required": "INTEGER NOT NULL DEFAULT 0",
    28|}
    29|
    30|CATALOG_OPTIONAL_COLUMNS = {
    31|    "last_price": "TEXT",
    32|    "price_currency": "TEXT",
    33|    "price_date": "TEXT",
    34|    "price_source": "TEXT",
    35|    "exchange_name": "TEXT",
    36|    "mic": "TEXT",
    37|    "security_type": "TEXT",
    38|}
    39|
    40|TEXT_AFFINITY_COLUMNS = {
    41|    "transactions": {"quantity", "price_original", "gross_amount_original", "fee_original", "tax_original", "net_amount_original", "fx_rate_to_chf", "gross_amount_chf", "fee_chf", "tax_chf", "net_amount_chf"},
    42|    "crypto_holdings": {"quantity", "legacy_snapshot_value_original", "legacy_snapshot_value_chf"},
    43|    "crypto_transactions": {"quantity", "price_original", "gross_amount_original", "fee_quantity", "fee_original", "fx_rate_to_chf", "amount_chf"},
    44|    "crypto_prices": {"price", "market_cap", "volume_24h", "change_24h_pct"},
    45|    "fx_rates": {"rate"},
    46|    "market_prices": {"open", "high", "low", "close", "adjusted_close"},
    47|}
    48|
    49|
    50|def utc_now() -> str:
    51|    return datetime.now(timezone.utc).isoformat()
    52|
    53|
    54|def checksum_sql(sql: str) -> str:
    55|    return hashlib.sha256(sql.encode("utf-8")).hexdigest()
    56|
    57|
    58|def get_schema_version(conn: Connection) -> int:
    59|    row = conn.execute("SELECT MAX(version) AS version FROM schema_migrations").fetchone()
    60|    return int(row["version"] or 0) if row else 0
    61|
    62|
    63|def _table_columns(conn: Connection, table: str) -> dict[str, str]:
    64|    return {row["name"]: (row["type"] or "") for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
    65|
    66|
    67|def _add_missing_instrument_columns(conn: Connection) -> None:
    68|    existing = _table_columns(conn, "instruments")
    69|    for name, col_type in INSTRUMENT_OPTIONAL_COLUMNS.items():
    70|        if name not in existing:
    71|            conn.execute(f"ALTER TABLE instruments ADD COLUMN {name} {col_type}")
    72|
    73|
    74|def _rebuild_table_with_text_columns(conn: Connection, table: str, text_columns: set[str]) -> None:
    75|    cols = conn.execute(f"PRAGMA table_info({table})").fetchall()
    76|    if not cols:
    77|        return
    78|    needs_rebuild = any(row["name"] in text_columns and (row["type"] or "").upper() != "TEXT" for row in cols)
    79|    if not needs_rebuild:
    80|        return
    81|    tmp = f"{table}__text_migration"
    82|    col_defs: list[str] = []
    83|    pk_cols = [row["name"] for row in cols if row["pk"]]
    84|    for row in cols:
    85|        name = row["name"]
    86|        col_type = "TEXT" if name in text_columns else (row["type"] or "TEXT")
    87|        parts = [name, col_type]
    88|        if row["pk"] and len(pk_cols) == 1:
    89|            parts.append("PRIMARY KEY")
    90|        if row["notnull"]:
    91|            parts.append("NOT NULL")
    92|        if row["dflt_value"] is not None:
    93|            parts.append(f"DEFAULT {row['dflt_value']}")
    94|        col_defs.append(" ".join(parts))
    95|    if len(pk_cols) > 1:
    96|        col_defs.append("PRIMARY KEY (" + ", ".join(pk_cols) + ")")
    97|    conn.execute(f"DROP TABLE IF EXISTS {tmp}")
    98|    conn.execute(f"CREATE TABLE {tmp} (" + ", ".join(col_defs) + ")")
    99|    names = [row["name"] for row in cols]
   100|    select_exprs = [f"CAST({name} AS TEXT)" if name in text_columns else name for name in names]
   101|    conn.execute(f"INSERT INTO {tmp} ({', '.join(names)}) SELECT {', '.join(select_exprs)} FROM {table}")
   102|    conn.execute(f"DROP TABLE {table}")
   103|    conn.execute(f"ALTER TABLE {tmp} RENAME TO {table}")
   104|    if table == "crypto_holdings":
   105|        conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_crypto_holdings_asset_wallet ON crypto_holdings(asset_id, wallet_id)")
   106|
   107|
   108|def _add_missing_columns(conn: Connection, table: str, columns: dict[str, str]) -> None:
   109|    existing = _table_columns(conn, table)
   110|    for name, col_type in columns.items():
   111|        if name not in existing:
   112|            conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {col_type}")
   113|
   114|
   115|def _create_broker_bank_mapping_tables(conn: Connection) -> None:
   116|    conn.executescript(
   117|        """
   118|        CREATE TABLE IF NOT EXISTS instrument_mappings (
   119|            mapping_id TEXT PRIMARY KEY,
   120|            source_name TEXT,
   121|            source_platform TEXT NOT NULL,
   122|            source_account TEXT,
   123|            source_label TEXT NOT NULL,
   124|            normalized_name TEXT NOT NULL,
   125|            isin TEXT,
   126|            ticker TEXT,
   127|            exchange TEXT,
   128|            currency TEXT,
   129|            asset_class TEXT NOT NULL DEFAULT 'other',
   130|            source_instrument_name TEXT,
   131|            source_isin TEXT,
   132|            source_ticker TEXT,
   133|            source_exchange TEXT,
   134|            source_currency TEXT,
   135|            instrument_id TEXT REFERENCES instruments(instrument_id),
   136|            mapping_status TEXT NOT NULL DEFAULT 'needs_manual_review',
   137|            confidence TEXT NOT NULL DEFAULT '0',
   138|            quality_flags_json TEXT,
   139|            notes TEXT,
   140|            created_at TEXT NOT NULL,
   141|            updated_at TEXT
   142|        );
   143|
   144|        CREATE TABLE IF NOT EXISTS platform_account_mappings (
   145|            mapping_id TEXT PRIMARY KEY,
   146|            source_name TEXT,
   147|            source_platform TEXT NOT NULL,
   148|            source_platform_label TEXT,
   149|            source_account_label TEXT NOT NULL,
   150|            normalized_platform TEXT NOT NULL,
   151|            normalized_account_name TEXT NOT NULL,
   152|            source_account_type TEXT,
   153|            account_type TEXT NOT NULL DEFAULT 'other',
   154|            source_currency TEXT,
   155|            currency TEXT,
   156|            platform_id TEXT REFERENCES platforms(platform_id),
   157|            account_id TEXT REFERENCES accounts(account_id),
   158|            internal_platform_id TEXT REFERENCES platforms(platform_id),
   159|            internal_account_id TEXT REFERENCES accounts(account_id),
   160|            mapping_status TEXT NOT NULL DEFAULT 'needs_manual_review',
   161|            quality_flags_json TEXT,
   162|            notes TEXT,
   163|            created_at TEXT NOT NULL,
   164|            updated_at TEXT
   165|        );
   166|
   167|        CREATE TABLE IF NOT EXISTS broker_import_dry_runs (
   168|            dry_run_id TEXT PRIMARY KEY,
   169|            source_name TEXT,
   170|            source_platform TEXT NOT NULL,
   171|            source_file_label TEXT,
   172|            source_file_type TEXT NOT NULL,
   173|            source_hash TEXT,
   174|            source_filename_hash TEXT,
   175|            detected_snapshot_date TEXT,
   176|            snapshot_date_status TEXT NOT NULL DEFAULT 'missing',
   177|            detected_sections_json TEXT NOT NULL,
   178|            field_coverage_json TEXT,
   179|            rows_total INTEGER NOT NULL DEFAULT 0,
   180|            rows_position_candidates INTEGER NOT NULL DEFAULT 0,
   181|            rows_cash_candidates INTEGER NOT NULL DEFAULT 0,
   182|            candidate_positions INTEGER NOT NULL DEFAULT 0,
   183|            candidate_cash_rows INTEGER NOT NULL DEFAULT 0,
   184|            mapped_positions INTEGER NOT NULL DEFAULT 0,
   185|            blocked_positions INTEGER NOT NULL DEFAULT 0,
   186|            warnings_count INTEGER NOT NULL DEFAULT 0,
   187|            errors_count INTEGER NOT NULL DEFAULT 0,
   188|            quality_flags_json TEXT NOT NULL,
   189|            summary_json TEXT NOT NULL DEFAULT '{}',
   190|            created_at TEXT NOT NULL,
   191|            notes TEXT
   192|        );
   193|        """
   194|    )
   195|    _add_missing_columns(conn, "instrument_mappings", {
   196|        "source_name": "TEXT",
   197|        "source_platform": "TEXT",
   198|        "source_account": "TEXT",
   199|        "source_label": "TEXT",
   200|        "normalized_name": "TEXT",
   201|        "isin": "TEXT",
   202|        "ticker": "TEXT",
   203|        "exchange": "TEXT",
   204|        "currency": "TEXT",
   205|        "asset_class": "TEXT DEFAULT 'other'",
   206|        "confidence": "TEXT DEFAULT '0'",
   207|    })
   208|    _add_missing_columns(conn, "platform_account_mappings", {
   209|        "source_platform": "TEXT",
   210|        "normalized_platform": "TEXT",
   211|        "normalized_account_name": "TEXT",
   212|        "account_type": "TEXT DEFAULT 'other'",
   213|        "currency": "TEXT",
   214|        "internal_platform_id": "TEXT",
   215|        "internal_account_id": "TEXT",
   216|    })
   217|    _add_missing_columns(conn, "broker_import_dry_runs", {
   218|        "source_platform": "TEXT",
   219|        "source_filename_hash": "TEXT",
   220|        "detected_snapshot_date": "TEXT",
   221|

Use offset=221 to continue reading (showing 1-220 of 1104 lines)


Ich habe ein paar Bruchkanten gefunden: Kategorie-API default würde alte Seiten brechen, `chartWidth` fehlt noch, Review-Summary ist noch nicht 2026-only, Overview zieht den letzten DB-Monat statt Budgetjahr 2026. Ich korrigiere diese Punkte jetzt vor den Full Gates.

patch

@router.get("/categories")
def categories(include_archived: bool = False, grouped: bool = True, conn: Connection = Depends(get_db)):
    if grouped:
        return list_category_tree_grouped(conn, include_archived=include_archived)
    return list_categories(conn, include_archived=include_archived)

@router.get("/categories")
def categories(include_archived: bool = False, grouped: bool = False, conn: Connection = Depends(get_db)):
    if grouped:
        return list_category_tree_grouped(conn, include_archived=include_archived)
    return list_categories(conn, include_archived=include_archived)

@router.get("/categories/tree")
def category_tree(include_archived: bool = False, conn: Connection = Depends(get_db)):
    return list_category_tree_grouped(conn, include_archived=include_archived)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -130,10 +130,15 @@

 @router.get("/categories")
-def categories(include_archived: bool = False, grouped: bool = True, conn: Connection = Depends(get_db)):
+def categories(include_archived: bool = False, grouped: bool = False, conn: Connection = Depends(get_db)):
     if grouped:
         return list_category_tree_grouped(conn, include_archived=include_archived)
     return list_categories(conn, include_archived=include_archived)
+
+
+@router.get("/categories/tree")
+def category_tree(include_archived: bool = False, conn: Connection = Depends(get_db)):
+    return list_category_tree_grouped(conn, include_archived=include_archived)

 @router.get("/tags")


def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
    rows = conn.execute("SELECT status, classification, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirmed','ignored','superseded','reference_2025','archived_reference')", (budget_year,)).fetchall()

def review_candidate_summary(conn: Connection) -> dict[str, int]:
    rows = conn.execute("SELECT status, classification, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','superseded')").fetchall()


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

-def review_candidate_summary(conn: Connection) -> dict[str, int]:
-    rows = conn.execute("SELECT status, classification, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','superseded')").fetchall()
+def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
+    rows = conn.execute("SELECT status, classification, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirmed','ignored','superseded','reference_2025','archived_reference')", (budget_year,)).fetchall()
     def count(pred):
         return sum(1 for r in rows if pred(r))
     return {

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


def transaction_candidate_summary(budget_year: str = "2026", conn: Connection = Depends(get_db)):
    return review_candidate_summary(conn, budget_year=budget_year)

def transaction_candidate_summary(conn: Connection = Depends(get_db)):
    return review_candidate_summary(conn)


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


   185|def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
   186|    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)
   187|
   188|
   189|@router.get("/transaction-candidates/summary")
   190|def transaction_candidates_summary(conn: Connection = Depends(get_db)):
   191|    return review_candidate_summary(conn)
   192|
   193|
   194|@router.post("/transaction-candidates/generate")
   195|def transaction_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   196|    source_type = payload.get("source_type") or "csv_seed"
   197|    rows = payload.get("rows") or []
   198|    if source_type == "credit_card_csv":
   199|        return seed_credit_card_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Credit card CSV", subscription_category_id=payload.get("subscription_category_id"), migros_covered=payload.get("migros_covered", True))
   200|    if source_type == "migros_csv":
   201|        return seed_migros_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Migros CSV", auto_category_id=payload.get("auto_category_id"), threshold_chf=str(payload.get("threshold_chf") or "50.00"))
   202|    if source_type == "bank_csv":
   203|        return seed_bank_transfer_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Bank CSV", income_category_id=payload.get("income_category_id"))
   204|    if payload.get("csv_text"):
   205|        return seed_transaction_candidates_from_csv_text(conn, payload["csv_text"], source_file_label=payload.get("source_file_label") or "CSV Import", source_type=source_type)
   206|    return seed_transaction_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Budget Import", source_type=source_type)
   207|
   208|
   209|@router.post("/transaction-candidates/{candidate_id}/preview")
   210|def transaction_candidates_preview(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   211|    return preview_transaction_candidate(conn, candidate_id, payload or {})
   212|
   213|
   214|@router.post("/transaction-candidates/{candidate_id}/confirm")
   215|def transaction_candidates_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   216|    return confirm_transaction_candidate(conn, candidate_id, payload)
   217|
   218|
   219|@router.post("/transaction-candidates/{candidate_id}/ignore")
   220|def transaction_candidates_ignore(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   221|    return ignore_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   222|
   223|
   224|@router.post("/transaction-candidates/{candidate_id}/reopen")
   225|def transaction_candidates_reopen(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   226|    return reopen_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   227|
   228|
   229|@router.post("/transaction-candidates/{candidate_id}/category")
   230|def transaction_candidates_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   231|    return update_transaction_candidate_category(conn, candidate_id, payload.get("category_id"), payload.get("note"))
   232|
   233|
   234|@router.post("/transaction-candidates/bulk-category")
   235|def transaction_candidates_bulk_category(payload: dict, conn: Connection = Depends(get_db)):
   236|    category_id = str(payload.get("category_id") or "")
   237|    return bulk_update_transaction_candidate_category(conn, candidate_ids=payload.get("candidate_ids") or [], category_id=category_id, note=payload.get("note"))
   238|
   239|
   240|@router.post("/transaction-candidates/{candidate_id}/split")
   241|def transaction_candidates_split(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   242|    return create_candidate_split(conn, candidate_id, payload.get("splits") or [])
   243|
   244|
   245|@router.post("/transaction-candidates/{candidate_id}/split/confirm")
   246|def transaction_candidates_split_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   247|    return confirm_candidate_split(conn, candidate_id, payload)
   248|
   249|
   250|@router.post("/transaction-candidates/{candidate_id}/category/create")
   251|def transaction_candidates_create_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   252|    return create_review_category_for_candidate(conn, candidate_id, payload)
   253|
   254|
   255|@router.post("/transaction-candidates/{candidate_id}/transfer")
   256|def transaction_candidates_transfer(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   257|    return mark_candidate_transfer(conn, candidate_id, (payload or {}).get("note"))
   258|
   259|
   260|@router.post("/transaction-candidates/batch-confirm-safe/preview")
   261|def transaction_candidates_batch_confirm_safe_preview(conn: Connection = Depends(get_db)):
   262|    return preview_safe_auto_candidates(conn)
   263|
   264|
   265|@router.post("/transaction-candidates/batch/preview")
   266|def transaction_candidates_batch_preview(payload: dict, conn: Connection = Depends(get_db)):
   267|    return preview_batch_confirm_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], account_id=str(payload.get("account_id") or ""), budget_year=str(payload.get("budget_year") or "2026"))
   268|
   269|
   270|@router.post("/transaction-candidates/batch")
   271|def transaction_candidates_batch(payload: dict, conn: Connection = Depends(get_db)):
   272|    return batch_mark_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], action=str(payload.get("action") or ""), account_id=payload.get("account_id"), category_id=payload.get("category_id"), preview_id=payload.get("preview_id"), note=payload.get("note"), budget_year=str(payload.get("budget_year") or "2026"))
   273|
   274|
   275|@router.post("/transaction-candidates/archive-reference-year")
   276|def transaction_candidates_archive_reference(payload: dict | None = None, conn: Connection = Depends(get_db)):
   277|    payload = payload or {}
   278|    return archive_reference_year_candidates(conn, active_year=str(payload.get("active_year") or "2026"), reference_year=str(payload.get("reference_year") or "2025"))
   279|
   280|
   281|@router.post("/transaction-candidates/batch-confirm-safe")
   282|def transaction_candidates_batch_confirm_safe(payload: dict, conn: Connection = Depends(get_db)):
   283|    account_id = str(payload.get("account_id") or "")
   284|    return batch_confirm_safe_candidates(conn, account_id=account_id, candidate_ids=payload.get("candidate_ids") or None)
   285|
   286|
   287|@router.post("/seed-candidates/generate")
   288|def seed_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   289|    return seed_budget_candidates_from_rows(conn, payload.get("workbook_rows") or {}, source_file_label=payload.get("source_file_label") or "Budget workbook")
   290|
   291|
   292|@router.post("/seed-candidates/{seed_candidate_id}/preview")
   293|def seed_candidates_preview(seed_candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   294|    return preview_seed_candidate(conn, seed_candidate_id, payload or {})
   295|
   296|
   297|@router.post("/seed-candidates/{seed_candidate_id}/confirm")
   298|def seed_candidates_confirm(seed_candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   299|    return confirm_seed_candidate(conn, seed_candidate_id, payload)
   300|
   301|
   302|@router.post("/seed-candidates/{seed_candidate_id}/ignore")
   303|def seed_candidates_ignore(seed_candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   304|    return ignore_seed_candidate(conn, seed_candidate_id, (payload or {}).get("note"))
   305|
   306|
   307|@router.post("/seed-candidates/{seed_candidate_id}/reopen")
   308|def seed_candidates_reopen(seed_candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   309|    return reopen_seed_candidate(conn, seed_candidate_id, (payload or {}).get("note"))
   310|
   311|
   312|@router.post("/seed-candidates/{seed_candidate_id}/status")
   313|def seed_candidates_status(seed_candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   314|    return set_seed_candidate_status(conn, seed_candidate_id, payload.get("status"), payload.get("note"))
   315|
   316|
   317|@router.post("/seed-candidates/{seed_candidate_id}/convert-unclean")
   318|def seed_candidates_convert_unclean(seed_candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   319|    return convert_unclean_range(conn, seed_candidate_id, payload.get("target_type"), payload)
   320|
   321|
   322|@router.post("/seed-candidates/categories/preview-clear-takeover")
   323|def seed_candidates_categories_preview_clear_takeover(conn: Connection = Depends(get_db)):
   324|    return preview_clear_category_takeover(conn)
   325|

Use offset=325 to continue reading (showing 185-324 of 439 lines)


@router.get("/transaction-candidates/summary")
def transaction_candidates_summary(budget_year: str = "2026", conn: Connection = Depends(get_db)):
    return review_candidate_summary(conn, budget_year=budget_year)

@router.get("/transaction-candidates/summary")
def transaction_candidates_summary(conn: Connection = Depends(get_db)):
    return review_candidate_summary(conn)


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

 @router.get("/transaction-candidates/summary")
-def transaction_candidates_summary(conn: Connection = Depends(get_db)):
-    return review_candidate_summary(conn)
+def transaction_candidates_summary(budget_year: str = "2026", conn: Connection = Depends(get_db)):
+    return review_candidate_summary(conn, budget_year=budget_year)

 @router.post("/transaction-candidates/generate")

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


export const getBudgetOverview = (month = '2026-01') => apiGet<BudgetOverview>(`/api/budget/overview?month=${month}`)

export const getBudgetOverview = () => apiGet<BudgetOverview>('/api/budget/overview')


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -32,7 +32,7 @@
 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; actual_year_to_date: string; forecast_year: string; status: string }>; forecast_overruns: Array<Record<string, string>> }
 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[] }

-export const getBudgetOverview = () => apiGet<BudgetOverview>('/api/budget/overview')
+export const getBudgetOverview = (month = '2026-01') => apiGet<BudgetOverview>(`/api/budget/overview?month=${month}`)
 export const getBudgetStatusByCategory = (year = '2026') => apiGet<BudgetStatusRow[]>(`/api/budget/status/categories?year=${year}`)
 export const getBudgetCharts = (year = '2026') => apiGet<BudgetCharts>(`/api/budget/charts?year=${year}`)
 export const getIncomeStatus = (year = '2026') => apiGet<Record<string, unknown>>(`/api/budget/income/status?year=${year}`)

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


async function refresh() { overview.value = await getBudgetOverview(`${activeYear.value}-01`); charts.value = await getBudgetCharts(activeYear.value); categories.value = await getBudgetCategories() }

async function refresh() { overview.value = await getBudgetOverview(); charts.value = await getBudgetCharts(activeYear.value); categories.value = await getBudgetCategories() }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -171,7 +171,7 @@
   if (status === 'Kritisch') return 'bg-red-900/60 text-red-100'
   return 'bg-slate-800 text-slate-300'
 }
-async function refresh() { overview.value = await getBudgetOverview(); charts.value = await getBudgetCharts(activeYear.value); categories.value = await getBudgetCategories() }
+async function refresh() { overview.value = await getBudgetOverview(`${activeYear.value}-01`); charts.value = await getBudgetCharts(activeYear.value); categories.value = await getBudgetCategories() }
 async function openCategory(row: BudgetStatusRow) { selected.value = row; filters.month = ''; filters.account_id = ''; filters.merchant = ''; filters.tag = ''; filters.status = ''; await loadTransactions() }
 async function loadTransactions() {
   if (!selected.value) return

No linter for .vue files


function chartWidth(actual: string, budget: string) {
  const a = Number(actual || 0); const b = Math.max(Number(budget || 0), 1)
  return `${Math.min(100, Math.round((a / b) * 100))}%`
}
async function refresh()


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -171,6 +171,10 @@
   if (status === 'Kritisch') return 'bg-red-900/60 text-red-100'
   return 'bg-slate-800 text-slate-300'
 }
+function chartWidth(actual: string, budget: string) {
+  const a = Number(actual || 0); const b = Math.max(Number(budget || 0), 1)
+  return `${Math.min(100, Math.round((a / b) * 100))}%`
+}
 async function refresh() { overview.value = await getBudgetOverview(`${activeYear.value}-01`); charts.value = await getBudgetCharts(activeYear.value); categories.value = await getBudgetCategories() }
 async function openCategory(row: BudgetStatusRow) { selected.value = row; filters.month = ''; filters.account_id = ''; filters.merchant = ''; filters.tag = ''; filters.status = ''; await loadTransactions() }
 async function loadTransactions() {

No linter for .vue files


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -57,7 +57,7 @@
               CHF {{ row.deviation_year }}
               {{ row.status }}

-
Noch keine bestätigten Budgetpläne.

+
Noch keine bestätigten Budgetpläne.

No linter for .vue files


export const getBudgetTransactionCandidateSummary = (budget_year = '2026') => apiGet<BudgetReviewSummary>(`/api/budget/transaction-candidates/summary?budget_year=${budget_year}`)

export const getBudgetTransactionCandidateSummary = () => apiGet<BudgetReviewSummary>('/api/budget/transaction-candidates/summary')


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -75,7 +75,7 @@
   const qs = params.toString()
   return apiGet<BudgetTransactionCandidate[]>(`/api/budget/transaction-candidates${qs ? `?${qs}` : ''}`)
 }
-export const getBudgetTransactionCandidateSummary = () => apiGet<BudgetReviewSummary>('/api/budget/transaction-candidates/summary')
+export const getBudgetTransactionCandidateSummary = (budget_year = '2026') => apiGet<BudgetReviewSummary>(`/api/budget/transaction-candidates/summary?budget_year=${budget_year}`)
 export const generateBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, number>>('/api/budget/transaction-candidates/generate', body)
 export const previewBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/transaction-candidates/${id}/preview`, body)
 export const confirmBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/confirm`, body)


async function load() { candidates.value = await getBudgetTransactionCandidates(status.value, activeTab.value, filters); summary.value = await getBudgetTransactionCandidateSummary(filters.budget_year); selected.value = selected.value.filter(id => candidates.value.some(c => c.transaction_candidate_id === id)); batchPreview.value = null }

async function load() { candidates.value = await getBudgetTransactionCandidates(status.value, activeTab.value, filters); summary.value = await getBudgetTransactionCandidateSummary(); selected.value = selected.value.filter(id => candidates.value.some(c => c.transaction_candidate_id === id)); batchPreview.value = null }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -100,7 +100,7 @@
 const selectedIds = computed(() => selected.value); const allVisibleSelected = computed(() => candidates.value.length > 0 && candidates.value.every(c => selected.value.includes(c.transaction_candidate_id)))
 const summaryCards = computed(() => [{ label: 'Offen total', value: summary.value?.open_total ?? 0 }, { label: 'Auto', value: summary.value?.auto_categorized ?? 0 }, { label: 'Review nötig', value: summary.value?.review_needed ?? 0 }, { label: 'Galaxus', value: summary.value?.galaxus_digitec ?? 0 }, { label: 'Migros > 50', value: summary.value?.migros_over_50 ?? 0 }, { label: 'Covered', value: summary.value?.covered_by_migros ?? 0 }, { label: 'Unklar', value: summary.value?.unclear ?? 0 }, { label: 'Duplikate', value: summary.value?.possible_duplicates ?? 0 }])
 const detailRows = computed(() => detail.value ? Object.entries(detail.value).map(([k, v]) => [k, String(v ?? '')]) : [])
-async function load() { candidates.value = await getBudgetTransactionCandidates(status.value, activeTab.value, filters); summary.value = await getBudgetTransactionCandidateSummary(); selected.value = selected.value.filter(id => candidates.value.some(c => c.transaction_candidate_id === id)); batchPreview.value = null }
+async function load() { candidates.value = await getBudgetTransactionCandidates(status.value, activeTab.value, filters); summary.value = await getBudgetTransactionCandidateSummary(filters.budget_year); selected.value = selected.value.filter(id => candidates.value.some(c => c.transaction_candidate_id === id)); batchPreview.value = null }
 async function refreshAll() { categories.value = await getBudgetCategories(); accounts.value = await getBudgetAccounts(); await load() }
 async function setTab(tab: string) { activeTab.value = tab; await load() }
 function toggleAll() { selected.value = allVisibleSelected.value ? [] : candidates.value.map(c => c.transaction_candidate_id) }

No linter for .vue files


async function confirmItem(item: BudgetTransactionCandidate) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; return } await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: item.classification === 'income_candidate' ? 'income' : 'expense', budget_year: filters.budget_year }); message.value = 'Kandidat bestätigt.'; error.value=''; await load() }

async function confirmItem(item: BudgetTransactionCandidate) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; return } await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: item.classification === 'income_candidate' ? 'income' : 'expense' }); message.value = 'Kandidat bestätigt.'; error.value=''; await load() }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -105,7 +105,7 @@
 async function setTab(tab: string) { activeTab.value = tab; await load() }
 function toggleAll() { selected.value = allVisibleSelected.value ? [] : candidates.value.map(c => c.transaction_candidate_id) }
 function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','superseded','transfer_candidate','confirmed','ignored'].includes(item.status) && (!!item.proposed_category_id || item.classification === 'income_candidate') }
-async function confirmItem(item: BudgetTransactionCandidate) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; return } await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: item.classification === 'income_candidate' ? 'income' : 'expense' }); message.value = 'Kandidat bestätigt.'; error.value=''; await load() }
+async function confirmItem(item: BudgetTransactionCandidate) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; return } await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: item.classification === 'income_candidate' ? 'income' : 'expense', budget_year: filters.budget_year }); message.value = 'Kandidat bestätigt.'; error.value=''; await load() }
 async function setCategory(item: BudgetTransactionCandidate, categoryId: string) { if (!categoryId) return; await updateBudgetTransactionCandidateCategory(item.transaction_candidate_id, categoryId, 'Inline category override'); message.value = 'Kategorie aktualisiert.'; await load() }
 async function bulkSetCategory() { const result = await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'category', category_id: bulkCategory.value, budget_year: filters.budget_year, note: 'Bulk category assignment' }); batchErrors.value = ((result as any).errors || []) as any; message.value='Auswahl kategorisiert.'; await load() }
 async function previewBatchConfirm() { if (!confirmAccount.value) { error.value='Confirm-Konto fehlt.'; return } batchErrors.value=[]; batchPreview.value = await previewBatchBudgetTransactionCandidates(selected.value, confirmAccount.value, filters.budget_year); error.value='' }

No linter for .vue files


function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','superseded','transfer_candidate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || item.classification === 'income_candidate') }

function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','superseded','transfer_candidate','confirmed','ignored'].includes(item.status) && (!!item.proposed_category_id || item.classification === 'income_candidate') }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -104,7 +104,7 @@
 async function refreshAll() { categories.value = await getBudgetCategories(); accounts.value = await getBudgetAccounts(); await load() }
 async function setTab(tab: string) { activeTab.value = tab; await load() }
 function toggleAll() { selected.value = allVisibleSelected.value ? [] : candidates.value.map(c => c.transaction_candidate_id) }
-function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','superseded','transfer_candidate','confirmed','ignored'].includes(item.status) && (!!item.proposed_category_id || item.classification === 'income_candidate') }
+function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','superseded','transfer_candidate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || item.classification === 'income_candidate') }
 async function confirmItem(item: BudgetTransactionCandidate) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; return } await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: item.classification === 'income_candidate' ? 'income' : 'expense', budget_year: filters.budget_year }); message.value = 'Kandidat bestätigt.'; error.value=''; await load() }
 async function setCategory(item: BudgetTransactionCandidate, categoryId: string) { if (!categoryId) return; await updateBudgetTransactionCandidateCategory(item.transaction_candidate_id, categoryId, 'Inline category override'); message.value = 'Kategorie aktualisiert.'; await load() }
 async function bulkSetCategory() { const result = await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'category', category_id: bulkCategory.value, budget_year: filters.budget_year, note: 'Bulk category assignment' }); batchErrors.value = ((result as any).errors || []) as any; message.value='Auswahl kategorisiert.'; await load() }

No linter for .vue files