     1|from __future__ import annotations
     2|
     3|import pytest
     4|from fastapi import HTTPException
     5|
     6|from jarvis_finance.services.budget_accounts import confirm_create_budget_account
     7|from jarvis_finance.services.budget_categories import confirm_create_category, list_category_tree_grouped, simplify_budget_categories_2026
     8|from jarvis_finance.services.budget_imports import (
     9|    archive_reference_year_candidates,
    10|    batch_mark_candidates,
    11|    list_transaction_candidates,
    12|    preview_batch_confirm_candidates,
    13|    seed_transaction_candidates_from_rows,
    14|)
    15|from jarvis_finance.services.budget_overview import get_budget_charts_2026, get_budget_status_by_category
    16|from jarvis_finance.services.budget_transactions import confirm_budget_transaction, list_budget_transactions
    17|from jarvis_finance.storage.database import connect_memory
    18|from jarvis_finance.storage.migrations import apply_migrations
    19|
    20|
    21|def db():
    22|    conn = connect_memory()
    23|    apply_migrations(conn)
    24|    return conn
    25|
    26|
    27|def base(conn):
    28|    account = confirm_create_budget_account(conn, {"name": "Budget 2026 Konto", "account_type": "cash", "currency": "CHF"})["entity_id"]
    29|    food = confirm_create_category(conn, {"name": "Essen & Haushalt", "category_type": "expense", "sort_order": 10})["entity_id"]
    30|    health = confirm_create_category(conn, {"name": "Gesundheit / Medizin", "category_type": "expense", "sort_order": 40})["entity_id"]
    31|    salary = confirm_create_category(conn, {"name": "Lohn Marcel", "category_type": "income", "sort_order": 10})["entity_id"]
    32|    return account, food, health, salary
    33|
    34|
    35|def test_phase110_review_and_transactions_default_to_budget_year_2026_and_block_2025_confirm() -> None:
    36|    conn = db(); account, food, _health, _salary = base(conn)
    37|    seed_transaction_candidates_from_rows(conn, [
    38|        {"Datum": "2025-12-30", "Beschreibung": "Alt Referenz", "Betrag": "10.00"},
    39|        {"Datum": "2026-01-03", "Beschreibung": "Aktuell", "Betrag": "11.00"},
    40|    ], source_file_label="phase110.csv")
    41|    rows = list_transaction_candidates(conn, budget_year="2026")
    42|    assert [r["description"] for r in rows] == ["Aktuell"]
    43|    ref = list_transaction_candidates(conn, budget_year="2025", include_reference=True)
    44|    assert [r["description"] for r in ref] == ["Alt Referenz"]
    45|    with pytest.raises(HTTPException):
    46|        preview_batch_confirm_candidates(conn, candidate_ids=[ref[0]["transaction_candidate_id"]], account_id=account, budget_year="2026")
    47|    cleanup = archive_reference_year_candidates(conn, active_year="2026", reference_year="2025")
    48|    assert cleanup["updated_count"] == 1
    49|    archived = conn.execute("SELECT status FROM budget_transaction_candidates WHERE description='Alt Referenz'").fetchone()["status"]
    50|    assert archived == "reference_2025"
    51|
    52|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "expense", "transaction_date": "2025-12-31", "description": "Old tx", "amount_original": "1.00", "currency_original": "CHF", "category_id": food})
    53|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "expense", "transaction_date": "2026-01-31", "description": "New tx", "amount_original": "2.00", "currency_original": "CHF", "category_id": food})
    54|    assert [t["description"] for t in list_budget_transactions(conn, transaction_type="expense", status="confirmed", year="2026")] == ["New tx"]
    55|
    56|
    57|def test_phase110_bulk_assignment_confirm_ignore_reports_per_candidate_errors() -> None:
    58|    conn = db(); account, food, health, _salary = base(conn)
    59|    seed_transaction_candidates_from_rows(conn, [
    60|        {"Datum": "2026-01-01", "Beschreibung": "A", "Betrag": "1.00"},
    61|        {"Datum": "2026-01-02", "Beschreibung": "B", "Betrag": "2.00"},
    62|        {"Datum": "2025-01-02", "Beschreibung": "Old", "Betrag": "3.00"},
    63|    ], source_file_label="phase110.csv")
    64|    ids = [r["transaction_candidate_id"] for r in list_transaction_candidates(conn, budget_year="2026")]
    65|    result = batch_mark_candidates(conn, candidate_ids=ids, action="category", category_id=food, budget_year="2026")
    66|    assert result["updated_count"] == 2
    67|    assert result["errors"] == []
    68|    preview = preview_batch_confirm_candidates(conn, candidate_ids=ids, account_id=account, budget_year="2026")
    69|    confirmed = batch_mark_candidates(conn, candidate_ids=ids, action="confirm", account_id=account, preview_id=preview["preview_id"], budget_year="2026")
    70|    assert confirmed["confirmed_count"] == 2
    71|    assert len(list_budget_transactions(conn, transaction_type="expense", status="confirmed", year="2026")) == 2
    72|
    73|    old_id = conn.execute("SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE transaction_date LIKE '2025-%'").fetchone()[0]
    74|    bad = batch_mark_candidates(conn, candidate_ids=[old_id], action="category", category_id=health, budget_year="2026")
    75|    assert bad["updated_count"] == 0
    76|    assert bad["errors"] and bad["errors"][0]["candidate_id"] == old_id
    77|
    78|
    79|def test_phase110_category_tree_grouped_and_cleanup_canonicalises_duplicates_and_uat() -> None:
    80|    conn = db(); _account, food, _health, salary = base(conn)
    81|    duplicate = confirm_create_category(conn, {"name": "Sonstiges", "category_type": "expense"})["entity_id"]
    82|    confirm_create_category(conn, {"name": "Sonstiges / Administration", "category_type": "expense"})
    83|    confirm_create_category(conn, {"name": "UAT12 Dummy", "category_type": "expense"})
    84|    confirm_create_category(conn, {"name": "Lohn Melanie", "category_type": "income"})
    85|    result = simplify_budget_categories_2026(conn)
    86|    assert result["ensured_income_count"] >= 5
    87|    assert result["ensured_expense_count"] >= 13
    88|    assert result["archived_test_count"] >= 1
    89|    tree = list_category_tree_grouped(conn)
    90|    assert [bucket["name"] for bucket in tree] == ["Einnahmen", "Ausgaben"]
    91|    income_names = {c["name"] for c in tree[0]["children"]}
    92|    expense_names = {c["name"] for c in tree[1]["children"]}
    93|    assert {"Lohn Marcel", "Lohn Melanie", "Rückerstattungen"}.issubset(income_names)
    94|    assert {"Essen & Haushalt", "Sonstiges / Administration", "Review nötig"}.issubset(expense_names)
    95|    assert not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (duplicate,)).fetchone()
    96|
    97|
    98|def test_phase110_budgetstatus_monthly_matrix_forecast_ampel_and_charts_are_2026_only() -> None:
    99|    conn = db(); account, food, _health, salary = base(conn)
   100|    conn.execute("INSERT INTO budget_plan_items(plan_item_id, category_id, name, plan_month, monthly_amount_chf, annual_amount_chf, cadence, is_fixed_cost, source_type, is_active, created_at, updated_at) VALUES ('p_food', ?, 'Food plan', '2026-01', '100.00', NULL, 'monthly', 0, 'manual', 1, '2026-01-01T00:00:00', '2026-01-01T00:00:00')", (food,))
   101|    conn.execute("INSERT INTO budget_plan_items(plan_item_id, category_id, name, plan_month, monthly_amount_chf, annual_amount_chf, cadence, is_fixed_cost, source_type, is_active, created_at, updated_at) VALUES ('p_salary', ?, 'Salary plan', '2026-01', '1000.00', NULL, 'monthly', 0, 'manual', 1, '2026-01-01T00:00:00', '2026-01-01T00:00:00')", (salary,))
   102|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "expense", "transaction_date": "2026-01-10", "description": "Food Jan", "amount_original": "50.00", "currency_original": "CHF", "category_id": food})
   103|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "expense", "transaction_date": "2026-02-10", "description": "Food Feb", "amount_original": "75.00", "currency_original": "CHF", "category_id": food})
   104|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "income", "transaction_date": "2026-01-25", "description": "Salary Jan", "amount_original": "1000.00", "currency_original": "CHF", "category_id": salary})
   105|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "expense", "transaction_date": "2025-01-10", "description": "Old Food", "amount_original": "999.00", "currency_original": "CHF", "category_id": food})
   106|    rows = get_budget_status_by_category(conn, year="2026")
   107|    food_row = next(r for r in rows if r["category_id"] == food)
   108|    assert food_row["monthly_actuals"]["01"] == "50.00"
   109|    assert food_row["monthly_actuals"]["02"] == "75.00"
   110|    assert food_row["actual_year_to_date"] == "125.00"
   111|    assert food_row["monthly_actuals"]["12"] == "0.00"
   112|    assert "999" not in food_row["actual_year_to_date"]
   113|    assert food_row["status"] in {"Grün", "Gelb", "Rot", "Kritisch"}
   114|    charts = get_budget_charts_2026(conn, year="2026")
   115|    assert charts["cashflow_by_month"][0]["month"] == "2026-01"
   116|    assert charts["budget_vs_actual_by_category"]
   117|    assert all(item["year"] == "2026" for item in charts["forecast_overruns"])
   118|


     1|from __future__ import annotations
     2|
     3|from decimal import Decimal
     4|
     5|from jarvis_finance.services.budget_accounts import confirm_create_budget_account
     6|from jarvis_finance.services.budget_categories import confirm_create_category
     7|from jarvis_finance.services.budget_overview import get_budget_overview, get_budget_status_by_category, get_income_status
     8|from jarvis_finance.services.budget_plans import confirm_budget_plan_item, list_income_plan_items, archive_budget_plan_item
     9|from jarvis_finance.services.budget_transactions import confirm_budget_transaction
    10|from jarvis_finance.storage.database import connect_memory
    11|from jarvis_finance.storage.migrations import apply_migrations
    12|
    13|
    14|def db():
    15|    conn = connect_memory()
    16|    apply_migrations(conn)
    17|    return conn
    18|
    19|
    20|def base(conn):
    21|    account = confirm_create_budget_account(conn, {"name": "Budget 2026", "account_type": "cash", "currency": "CHF"})["entity_id"]
    22|    food = confirm_create_category(conn, {"name": "Essen & Haushalt", "category_type": "expense", "sort_order": 10})["entity_id"]
    23|    grocery = confirm_create_category(conn, {"name": "Lebensmittel", "category_type": "expense", "parent_category_id": food, "sort_order": 11})["entity_id"]
    24|    health = confirm_create_category(conn, {"name": "Gesundheit / Medizin", "category_type": "expense", "sort_order": 40})["entity_id"]
    25|    salary = confirm_create_category(conn, {"name": "Lohn Marcel", "category_type": "income", "sort_order": 10})["entity_id"]
    26|    bonus = confirm_create_category(conn, {"name": "Gratifikation", "category_type": "income", "sort_order": 30})["entity_id"]
    27|    unused = confirm_create_category(conn, {"name": "Haustiere", "category_type": "expense", "sort_order": 90})["entity_id"]
    28|    return account, food, grocery, health, salary, bonus, unused
    29|
    30|
    31|def test_phase111_budgetstatus_includes_actual_only_and_plan_only_but_not_empty_categories() -> None:
    32|    conn = db(); account, food, grocery, health, salary, _bonus, unused = base(conn)
    33|    confirm_budget_plan_item(conn, {"plan_month": "2026-01", "category_id": health, "name": "Health plan", "monthly_amount_chf": "100.00", "cadence": "monthly"})
    34|    confirm_budget_plan_item(conn, {"plan_month": "2026-01", "category_id": salary, "name": "Salary plan", "monthly_amount_chf": "1000.00", "cadence": "monthly"})
    35|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "expense", "transaction_date": "2026-01-10", "description": "Groceries", "amount_original": "42.25", "currency_original": "CHF", "category_id": food})
    36|    rows = get_budget_status_by_category(conn, year="2026")
    37|    by_id = {r["category_id"]: r for r in rows}
    38|    assert food in by_id
    39|    assert health in by_id
    40|    assert salary in by_id
    41|    assert unused not in by_id
    42|    assert by_id[food]["budget_month"] is None
    43|    assert by_id[food]["budget_year"] is None
    44|    assert by_id[food]["status"] == "ohne Budget"
    45|    assert by_id[food]["status_hint"] == "Ausgaben vorhanden, aber kein Budgetplan"
    46|    assert by_id[food]["actual_year_to_date"] == "42.25"
    47|    assert by_id[food]["monthly_actuals"]["01"] == "42.25"
    48|    assert by_id[health]["status"] == "Noch keine Daten"
    49|
    50|
    51|def test_phase111_subcategory_bookings_are_visible_and_parent_aggregates() -> None:
    52|    conn = db(); account, food, grocery, *_ = base(conn)
    53|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "expense", "transaction_date": "2026-02-11", "description": "Sub Food", "amount_original": "25.75", "currency_original": "CHF", "category_id": grocery})
    54|    rows = get_budget_status_by_category(conn, year="2026")
    55|    by_id = {r["category_id"]: r for r in rows}
    56|    assert grocery in by_id
    57|    assert food in by_id
    58|    assert by_id[grocery]["actual_year_to_date"] == "25.75"
    59|    assert by_id[food]["actual_year_to_date"] == "25.75"
    60|    assert by_id[food]["rollup_child_count"] >= 1
    61|    assert by_id[food]["is_rollup"] is True
    62|
    63|
    64|def test_phase111_income_plan_helpers_calculate_monthly_and_annual_and_overview_uses_income() -> None:
    65|    conn = db(); account, _food, _grocery, _health, salary, bonus, _unused = base(conn)
    66|    monthly = confirm_budget_plan_item(conn, {"plan_month": "2026-01", "category_id": salary, "name": "Lohn Marcel", "monthly_amount_chf": "1000.00", "cadence": "monthly", "notes": "person=Marcel;income_type=Lohn"})
    67|    annual = confirm_budget_plan_item(conn, {"plan_month": "2026-01", "category_id": bonus, "name": "Bonus", "annual_amount_chf": "1200.00", "cadence": "annual", "notes": "person=Haushalt;income_type=Gratifikation"})
    68|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "income", "transaction_date": "2026-01-25", "description": "Salary", "amount_original": "1000.00", "currency_original": "CHF", "category_id": salary})
    69|    plans = list_income_plan_items(conn, year="2026")
    70|    by_id = {p["plan_item_id"]: p for p in plans}
    71|    assert by_id[monthly["entity_id"]]["monthly_value_chf"] == "1000.00"
    72|    assert by_id[monthly["entity_id"]]["annual_value_chf"] == "12000.00"
    73|    assert by_id[annual["entity_id"]]["monthly_value_chf"] == "100.00"
    74|    assert by_id[annual["entity_id"]]["annual_value_chf"] == "1200.00"
    75|    income = get_income_status(conn, year="2026")
    76|    assert Decimal(income["planned_income_year"]) == Decimal("13200.00")
    77|    overview = get_budget_overview(conn, "2026-01")
    78|    assert Decimal(overview["planned_income_chf"]) > Decimal("0")
    79|    assert Decimal(overview["actual_income_chf"]) > Decimal("0")
    80|    archived = archive_budget_plan_item(conn, monthly["entity_id"])
    81|    assert archived["status"] == "archived"
    82|    assert all(p["plan_item_id"] != monthly["entity_id"] for p in list_income_plan_items(conn, year="2026", include_archived=False))
    83|


Red tests first. I can add test file. Need know migrations. We'll write tests expected fail. Need functions names. Implement new service functions. Need migrations? Existing line items. Need transactions list can include source_candidate_id. Read budget_transactions.

Explanation:
**Considering migration schema**

I’m thinking I might need a migrations schema, which is pretty crucial when working with databases. It could help manage changes in the database structure over time. I wonder if using `sqlite pragma` could be helpful for that purpose. It’s interesting how managing a database can be both challenging and rewarding, but I aim to make it smoother for myself. Just need to approach this step by step!


     1|from __future__ import annotations
     2|
     3|from decimal import Decimal
     4|from sqlite3 import Connection
     5|
     6|from fastapi import HTTPException
     7|
     8|from jarvis_finance.audit.log import record_audit_event
     9|from jarvis_finance.services.budget_common import new_id, now, require_decimal_text, row_to_dict, validate_currency
    10|
    11|TX_TYPES = {"income", "expense", "transfer", "refund", "fee", "adjustment", "reversal"}
    12|
    13|
    14|def _fx_fields(conn: Connection, currency: str, amount_text: str) -> tuple[str, str | None, str | None]:
    15|    cur = validate_currency(currency)
    16|    if cur == "CHF":
    17|        return "not_needed", "1", amount_text
    18|    row = conn.execute(
    19|        "SELECT rate FROM fx_rates WHERE base_currency=? AND quote_currency='CHF' ORDER BY rate_date DESC LIMIT 1",
    20|        (cur,),
    21|    ).fetchone()
    22|    if not row or row["rate"] in (None, ""):
    23|        return "missing", None, None
    24|    rate = Decimal(str(row["rate"]))
    25|    return "ok", format(rate, "f"), format(Decimal(amount_text) * rate, "f")
    26|
    27|
    28|def _normalise_tx_payload(conn: Connection, payload: dict, *, force_type: str | None = None) -> dict:
    29|    account_id = str(payload.get("account_id") or "").strip()
    30|    if not conn.execute("SELECT 1 FROM budget_accounts WHERE budget_account_id=? AND is_active=1", (account_id,)).fetchone():
    31|        raise HTTPException(status_code=422, detail="active budget account required")
    32|    tx_type = force_type or str(payload.get("transaction_type") or "expense").strip().lower()
    33|    if tx_type not in TX_TYPES:
    34|        raise HTTPException(status_code=422, detail="invalid transaction_type")
    35|    description = str(payload.get("description") or "").strip()
    36|    if not description:
    37|        raise HTTPException(status_code=422, detail="description is required")
    38|    amount = require_decimal_text(str(payload.get("amount_original") or ""), "amount_original")
    39|    currency = validate_currency(payload.get("currency_original") or "CHF")
    40|    fx_status, fx_rate, amount_chf = _fx_fields(conn, currency, amount)
    41|    if payload.get("fx_rate_to_chf"):
    42|        fx_rate = require_decimal_text(str(payload["fx_rate_to_chf"]), "fx_rate_to_chf")
    43|        amount_chf = format(Decimal(amount) * Decimal(fx_rate), "f")
    44|        fx_status = "not_needed" if currency == "CHF" else "manual_override"
    45|    return {
    46|        "budget_transaction_id": payload.get("budget_transaction_id") or new_id("btx"),
    47|        "account_id": account_id,
    48|        "transaction_type": tx_type,
    49|        "transaction_date": str(payload.get("transaction_date") or now()[:10]),
    50|        "booking_date": payload.get("booking_date") or None,
    51|        "description": description,
    52|        "payee": payload.get("payee") or None,
    53|        "merchant_id": payload.get("merchant_id") or None,
    54|        "amount_original": amount,
    55|        "currency_original": currency,
    56|        "fx_rate_to_chf": fx_rate,
    57|        "amount_chf": amount_chf,
    58|        "fx_status": fx_status,
    59|        "category_id": payload.get("category_id") or None,
    60|        "source_type": payload.get("source_type") or "manual",
    61|        "source_candidate_id": payload.get("source_candidate_id") or None,
    62|        "notes": payload.get("notes") or None,
    63|        "tag_names": payload.get("tag_names") or [],
    64|        "reversal_of_transaction_id": payload.get("reversal_of_transaction_id") or None,
    65|    }
    66|
    67|
    68|def create_budget_transaction_preview(conn: Connection, payload: dict) -> dict:
    69|    tx = _normalise_tx_payload(conn, payload)
    70|    warnings = []
    71|    if tx["fx_status"] == "missing":
    72|        warnings.append("FX fehlt: CHF-Wert bleibt leer, Buchung kann bewusst unvollständig gespeichert werden.")
    73|    if not tx["category_id"] and tx["transaction_type"] in {"income", "expense", "refund", "fee"}:
    74|        warnings.append("Kategorie fehlt: Buchung bleibt unkategorisiert.")
    75|    return {"preview_id": new_id("preview"), "summary": f"{tx['transaction_type']} · {tx['currency_original']} {tx['amount_original']} · {tx['description']}", "fx_status": tx["fx_status"], "warnings": warnings, "payload": tx}
    76|
    77|
    78|def _insert_tags(conn: Connection, tx_id: str, tag_names: list[str]) -> None:
    79|    for name in tag_names:
    80|        row = conn.execute("SELECT tag_id FROM budget_tags WHERE name=?", (name,)).fetchone()
    81|        if row:
    82|            conn.execute("INSERT OR IGNORE INTO budget_transaction_tags(budget_transaction_id, tag_id) VALUES (?, ?)", (tx_id, row["tag_id"]))
    83|
    84|
    85|def confirm_budget_transaction(conn: Connection, payload: dict) -> dict:
    86|    tx = _normalise_tx_payload(conn, payload)
    87|    if tx["category_id"] and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=?", (tx["category_id"],)).fetchone():
    88|        raise HTTPException(status_code=422, detail="category not found")
    89|    ts = now()
    90|    conn.execute(
    91|        """
    92|        INSERT INTO budget_transactions(
    93|            budget_transaction_id, account_id, transaction_type, transaction_date, booking_date,
    94|            description, payee, merchant_id, amount_original, currency_original, fx_rate_to_chf,
    95|            amount_chf, fx_status, category_id, status, source_type, notes, created_at, updated_at,
    96|            reversal_of_transaction_id, source_candidate_id
    97|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'confirmed', ?, ?, ?, ?, ?, ?)
    98|        """,
    99|        (tx["budget_transaction_id"], tx["account_id"], tx["transaction_type"], tx["transaction_date"], tx["booking_date"], tx["description"], tx["payee"], tx["merchant_id"], tx["amount_original"], tx["currency_original"], tx["fx_rate_to_chf"], tx["amount_chf"], tx["fx_status"], tx["category_id"], tx["source_type"], tx["notes"], ts, ts, tx["reversal_of_transaction_id"], tx["source_candidate_id"]),
   100|    )
   101|    _insert_tags(conn, tx["budget_transaction_id"], tx["tag_names"])
   102|    audit_id = record_audit_event(conn, source="vue_dashboard", action="confirm", entity_type="budget_transaction", entity_id=tx["budget_transaction_id"], new_values=tx, user_text_note=tx.get("notes"), created_by="user")
   103|    conn.commit()
   104|    return {"status": "confirmed", "entity_id": tx["budget_transaction_id"], "audit_id": audit_id, "message": "Budgetbuchung gespeichert"}
   105|
   106|
   107|def reverse_budget_transaction_preview(conn: Connection, transaction_id: str, payload: dict | None = None) -> dict:
   108|    row = conn.execute("SELECT * FROM budget_transactions WHERE budget_transaction_id=? AND status='confirmed'", (transaction_id,)).fetchone()
   109|    if not row:
   110|        raise HTTPException(status_code=404, detail="confirmed transaction not found")
   111|    reason = (payload or {}).get("reason") or "Reversal"
   112|    reversal = row_to_dict(row) | {"budget_transaction_id": new_id("btx"), "transaction_type": "reversal", "description": f"Reversal: {row['description']}", "notes": reason, "reversal_of_transaction_id": transaction_id, "tag_names": []}
   113|    return {"preview_id": new_id("preview"), "summary": f"Reversal · {row['description']}", "warnings": [], "payload": reversal}
   114|
   115|
   116|def confirm_reverse_budget_transaction(conn: Connection, transaction_id: str, payload: dict) -> dict:
   117|    result = confirm_budget_transaction(conn, payload | {"reversal_of_transaction_id": transaction_id, "transaction_type": "reversal"})
   118|    ts = now()
   119|    conn.execute("UPDATE budget_transactions SET status='reversed', updated_at=? WHERE budget_transaction_id=?", (ts, transaction_id))
   120|    record_audit_event(conn, source="vue_dashboard", action="reverse", entity_type="budget_transaction", entity_id=transaction_id, new_values={"status": "reversed", "reversal_transaction_id": result["entity_id"]}, user_text_note=payload.get("notes"), created_by="user")
   121|    conn.commit()
   122|    return result
   123|
   124|
   125|def create_transfer_preview(conn: Connection, payload: dict) -> dict:
   126|    from_account_id = str(payload.get("from_account_id") or "").strip()
   127|    to_account_id = str(payload.get("to_account_id") or "").strip()
   128|    if from_account_id == to_account_id:
   129|        raise HTTPException(status_code=422, detail="transfer accounts must differ")
   130|    for account_id in (from_account_id, to_account_id):
   131|        if not conn.execute("SELECT 1 FROM budget_accounts WHERE budget_account_id=? AND is_active=1", (account_id,)).fetchone():
   132|            raise HTTPException(status_code=422, detail="active transfer accounts required")
   133|    amount = require_decimal_text(str(payload.get("amount_original") or ""), "amount_original")
   134|    currency = validate_currency(payload.get("currency_original") or "CHF")
   135|    fx_status, fx_rate, amount_chf = _fx_fields(conn, currency, amount)
   136|    transfer = {
   137|        "transfer_id": payload.get("transfer_id") or new_id("btrf"),
   138|        "from_account_id": from_account_id,
   139|        "to_account_id": to_account_id,
   140|        "amount_original": amount,
   141|        "currency_original": currency,
   142|        "fx_rate_to_chf": fx_rate,
   143|        "amount_chf": amount_chf,
   144|        "transaction_date": str(payload.get("transaction_date") or now()[:10]),
   145|        "description": str(payload.get("description") or "Transfer"),
   146|        "notes": payload.get("notes") or None,
   147|        "fx_status": fx_status,
   148|    }
   149|    return {"preview_id": new_id("preview"), "summary": f"Transfer · {currency} {amount}", "fx_status": fx_status, "warnings": [] if fx_status != "missing" else ["FX fehlt"], "payload": transfer}
   150|
   151|
   152|def confirm_transfer(conn: Connection, payload: dict) -> dict:
   153|    transfer = create_transfer_preview(conn, payload)["payload"]
   154|    out_payload = {"account_id": transfer["from_account_id"], "transaction_type": "transfer", "transaction_date": transfer["transaction_date"], "description": transfer["description"], "amount_original": transfer["amount_original"], "currency_original": transfer["currency_original"], "fx_rate_to_chf": transfer.get("fx_rate_to_chf"), "notes": transfer.get("notes")}
   155|    in_payload = out_payload | {"account_id": transfer["to_account_id"]}
   156|    out_result = confirm_budget_transaction(conn, out_payload)
   157|    in_result = confirm_budget_transaction(conn, in_payload)
   158|    ts = now()
   159|    conn.execute(
   160|        "INSERT INTO budget_transfers(transfer_id, from_transaction_id, to_transaction_id, from_account_id, to_account_id, amount_original, currency_original, fx_rate_to_chf, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
   161|        (transfer["transfer_id"], out_result["entity_id"], in_result["entity_id"], transfer["from_account_id"], transfer["to_account_id"], transfer["amount_original"], transfer["currency_original"], transfer.get("fx_rate_to_chf"), transfer.get("notes"), ts),
   162|    )
   163|    audit_id = record_audit_event(conn, source="vue_dashboard", action="confirm", entity_type="budget_transfer", entity_id=transfer["transfer_id"], new_values=transfer, created_by="user")
   164|    conn.commit()
   165|    return {"status": "confirmed", "entity_id": transfer["transfer_id"], "audit_id": audit_id, "message": "Transfer gespeichert"}
   166|
   167|
   168|def update_budget_transaction(conn: Connection, transaction_id: str, payload: dict) -> dict:
   169|    row = conn.execute("SELECT * FROM budget_transactions WHERE budget_transaction_id=?", (transaction_id,)).fetchone()
   170|    if not row:
   171|        raise HTTPException(status_code=404, detail="transaction not found")
   172|    forbidden_amount_fields = {"amount_original", "amount_chf", "currency_original", "fx_rate_to_chf"} & set(payload)
   173|    if forbidden_amount_fields:
   174|        raise HTTPException(status_code=422, detail="amount corrections require adjustment or reversal")
   175|    old = row_to_dict(row)
   176|    category_id = payload.get("category_id", row["category_id"])
   177|    if category_id in ("", "uncategorized"):
   178|        category_id = None
   179|    if category_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone():
   180|        raise HTTPException(status_code=422, detail="active category not found")
   181|    description = str(payload.get("description") or row["description"]).strip()
   182|    payee = payload.get("payee", row["payee"])
   183|    merchant_id = payload.get("merchant_id", row["merchant_id"])
   184|    notes = payload.get("notes", row["notes"])
   185|    ts = now()
   186|    conn.execute(
   187|        "UPDATE budget_transactions SET category_id=?, description=?, payee=?, merchant_id=?, notes=?, updated_at=? WHERE budget_transaction_id=?",
   188|        (category_id, description, payee, merchant_id, notes, ts, transaction_id),
   189|    )
   190|    if "tag_names" in payload:
   191|        conn.execute("DELETE FROM budget_transaction_tags WHERE budget_transaction_id=?", (transaction_id,))
   192|        _insert_tags(conn, transaction_id, payload.get("tag_names") or [])
   193|    new_values = {"category_id": category_id, "description": description, "payee": payee, "merchant_id": merchant_id, "notes": notes, "tag_names": payload.get("tag_names")}
   194|    audit_id = record_audit_event(conn, source="vue_dashboard", action="update", entity_type="budget_transaction", entity_id=transaction_id, old_values=old, new_values=new_values, user_text_note=payload.get("change_note"), created_by="user")
   195|    conn.commit()
   196|    return {"status": "updated", "entity_id": transaction_id, "audit_id": audit_id, "message": "Buchung aktualisiert"}
   197|
   198|
   199|def list_budget_transactions(conn: Connection, *, month: str | None = None, year: str | None = "2026", account_id: str | None = None, category_id: str | None = None, transaction_type: str | None = None, merchant: str | None = None, tag: str | None = None, status: str | None = None, source_type: str | None = None, search: str | None = None) -> list[dict]:
   200|    clauses = ["1=1"]
   201|    params: list[str] = []
   202|    if month:
   203|        clauses.append("substr(t.transaction_date,1,7)=?"); params.append(month)
   204|    elif year:
   205|        clauses.append("substr(t.transaction_date,1,4)=?"); params.append(year)
   206|    if account_id:
   207|        clauses.append("t.account_id=?"); params.append(account_id)
   208|    if category_id:
   209|        if category_id in {"__uncategorized", "uncategorized"}:
   210|            clauses.append("t.category_id IS NULL")
   211|        else:
   212|            clauses.append("t.category_id=?"); params.append(category_id)
   213|    if transaction_type:
   214|        clauses.append("t.transaction_type=?"); params.append(transaction_type)
   215|    if merchant:
   216|        clauses.append("(lower(COALESCE(t.payee,'')) LIKE lower(?) OR lower(t.description) LIKE lower(?))"); params.extend([f"%{merchant}%", f"%{merchant}%"])
   217|    if search:
   218|        clauses.append("(lower(COALESCE(t.payee,'')) LIKE lower(?) OR lower(t.description) LIKE lower(?) OR lower(COALESCE(c.name,'')) LIKE lower(?))"); params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
   219|    if source_type:
   220|        clauses.append("t.source_type=?"); params.append(source_type)
   221|    if status:
   222|        clauses.append("t.status=?"); params.append(status)
   223|    if tag:
   224|        clauses.append("EXISTS (SELECT 1 FROM budget_transaction_tags xt JOIN budget_tags xg ON xg.tag_id=xt.tag_id WHERE xt.budget_transaction_id=t.budget_transaction_id AND lower(xg.name)=lower(?))"); params.append(tag)
   225|    rows = conn.execute(
   226|        f"""
   227|        SELECT t.*, a.name AS account_name, c.name AS category_name
   228|        FROM budget_transactions t
   229|        JOIN budget_accounts a ON a.budget_account_id=t.account_id
   230|        LEFT JOIN budget_categories c ON c.category_id=t.category_id
   231|        WHERE {' AND '.join(clauses)}
   232|        ORDER BY t.transaction_date DESC, t.created_at DESC
   233|        LIMIT 200
   234|        """,
   235|        params,
   236|    ).fetchall()
   237|    return [row_to_dict(r) for r in rows]
   238|


Ich habe die relevanten Stellen gefunden: Migros hat noch die alte `<50 CHF auto / >=50 Review`-Logik, und Budgetstatus hat bereits Monatswerte, aber keinen echten Row-Click-Monatschart/Chart-Click-Drilldown. Ich schreibe jetzt gezielte RED-Tests gegen das gewünschte Verhalten.

from __future__ import annotations

from decimal import Decimal

from jarvis_finance.services.budget_accounts import confirm_create_budget_account
from jarvis_finance.services.budget_categories import confirm_create_category
from jarvis_finance.services.budget_imports import (
    confirm_migros_receipts_2026,
    list_transaction_candidates,
    preview_migros_receipts_2026,
    seed_credit_card_candidates_from_rows,
    seed_migros_candidates_from_rows,
)
from jarvis_finance.services.budget_overview import get_budget_status_by_category, get_category_month_detail
from jarvis_finance.services.budget_plans import confirm_budget_plan_item
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations

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

def base(conn):
    account = confirm_create_budget_account(conn, {"name": "Budgetkonto", "account_type": "cash", "currency": "CHF"})["entity_id"]
    food = confirm_create_category(conn, {"name": "Essen & Haushalt", "category_type": "expense", "sort_order": 10})["entity_id"]
    other = confirm_create_category(conn, {"name": "Sonstiges / Administration", "category_type": "expense", "sort_order": 90})["entity_id"]
    return account, food, other

def test_migros_receipts_are_always_food_household_and_can_be_confirmed_with_audit_without_article_transactions() -> None:
    conn = db(); account, food, _other = base(conn)
    rows = [
        {"Datum": "03.01.2026", "Zeit": "10:00", "Filiale": "Migros Test", "Kasse": "1", "Transaktion": "A", "Artikel": "Brot", "Umsatz": "30.00"},
        {"Datum": "03.01.2026", "Zeit": "10:00", "Filiale": "Migros Test", "Kasse": "1", "Transaktion": "A", "Artikel": "Haushalt", "Umsatz": "31.00"},
        {"Datum": "04.01.2026", "Zeit": "11:00", "Filiale": "Migros Test", "Kasse": "2", "Transaktion": "B", "Artikel": "Milch", "Umsatz": "12.00"},
    ]
    seed = seed_migros_candidates_from_rows(conn, rows, source_file_label="migros.csv", auto_category_id=food, threshold_chf="50.00")
    assert seed["receipt_candidate_count"] == 2
    candidates = list_transaction_candidates(conn, source_type="migros_receipt", include_reference=True)
    assert len(candidates) == 2
    assert {c["proposed_category_id"] for c in candidates} == {food}
    assert {c["status"] for c in candidates} == {"auto_categorized"}
    assert all(c["requires_review"] is False for c in candidates)
    assert all(c["classification"] == "migros_receipt_food_household" for c in candidates)

    preview = preview_migros_receipts_2026(conn)
    assert preview["receipt_candidate_count"] == 2
    assert preview["open_receipt_count"] == 2
    assert preview["line_item_count"] == 3
    assert preview["covered_by_migros_count"] == 0
    assert "amount" not in preview

    confirmed = confirm_migros_receipts_2026(conn, account_id=account)
    assert confirmed["confirmed_count"] == 2
    assert confirmed["line_item_transaction_count"] == 0
    assert confirmed["audit_id"]
    tx_rows = conn.execute("SELECT * FROM budget_transactions WHERE source_type='import_candidate'").fetchall()
    assert len(tx_rows) == 2
    assert {row["category_id"] for row in tx_rows} == {food}
    assert all("Brot" not in row["description"] and "Milch" not in row["description"] for row in tx_rows)
    assert conn.execute("SELECT COUNT(*) FROM budget_import_line_items").fetchone()[0] == 3
    assert conn.execute("SELECT COUNT(*) FROM audit_log WHERE action IN ('migros_receipts_2026_confirmed','candidate_confirmed')").fetchone()[0] >= 1

def test_credit_card_migros_stays_covered_and_is_not_confirmed_as_duplicate_expense() -> None:
    conn = db(); account, food, _other = base(conn)
    seed_migros_candidates_from_rows(conn, [{"Datum": "05.01.2026", "Zeit": "10:00", "Filiale": "Migros Test", "Kasse": "1", "Transaktion": "A", "Artikel": "Bon", "Umsatz": "44.00"}], source_file_label="migros.csv", auto_category_id=food)
    seed_credit_card_candidates_from_rows(conn, [{"Datum": "05.01.2026", "Beschreibung": "MIGROS MMM", "Betrag": "44.00"}], source_file_label="card.csv", migros_covered=True)

    preview = preview_migros_receipts_2026(conn)
    assert preview["covered_by_migros_count"] == 1
    confirm_migros_receipts_2026(conn, account_id=account)
    assert conn.execute("SELECT COUNT(*) FROM budget_transactions WHERE description LIKE '%MIGROS MMM%'").fetchone()[0] == 0
    covered = list_transaction_candidates(conn, status="covered_by_migros", include_reference=True)
    assert len(covered) == 1
    assert covered[0]["status"] == "covered_by_migros"

def test_budgetstatus_monthly_analytics_and_month_detail_groupings() -> None:
    conn = db(); account, food, other = base(conn)
    confirm_budget_plan_item(conn, {"plan_month": "2026-01", "category_id": food, "name": "Food plan", "monthly_amount_chf": "100.00", "cadence": "monthly"})
    seed_migros_candidates_from_rows(conn, [
        {"Datum": "10.01.2026", "Zeit": "10:00", "Filiale": "Migros A", "Kasse": "1", "Transaktion": "A", "Artikel": "Bon", "Umsatz": "120.00"},
        {"Datum": "11.02.2026", "Zeit": "10:00", "Filiale": "Migros B", "Kasse": "1", "Transaktion": "B", "Artikel": "Bon", "Umsatz": "40.00"},
    ], source_file_label="migros.csv", auto_category_id=food)
    confirm_migros_receipts_2026(conn, account_id=account)

    rows = get_budget_status_by_category(conn, year="2026")
    food_row = next(r for r in rows if r["category_id"] == food)
    jan = next(m for m in food_row["monthly_analysis"] if m["month"] == "2026-01")
    feb = next(m for m in food_row["monthly_analysis"] if m["month"] == "2026-02")
    assert jan["actual_chf"] == "120.00"
    assert jan["budget_chf"] == "100.00"
    assert jan["status"] == "Rot"
    assert jan["bar_color"] == "red"
    assert feb["actual_chf"] == "40.00"
    assert feb["status"] == "Grün"
    assert feb["bar_color"] == "green"

    detail = get_category_month_detail(conn, category_id=food, month="2026-01")
    assert detail["category_id"] == food
    assert detail["month"] == "2026-01"
    assert detail["budget_month"] == "100.00"
    assert detail["actual_chf"] == "120.00"
    assert detail["difference_chf"] == "20.00"
    assert Decimal(detail["difference_percent"]) == Decimal("20.00")
    assert detail["status"] == "Rot"
    assert detail["breakdown"]
    assert detail["breakdown_mode"] in {"merchant", "source"}
    assert len(detail["transactions"]) == 1
    assert detail["transactions"][0]["category_id"] == food
    assert detail["transactions"][0]["line_item_count"] == 1
    assert "expense_filter_url" in detail
    assert "year=2026" in detail["expense_filter_url"] and "month=2026-01" in detail["expense_filter_url"]


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_phase111_migros_monthly_analytics.py">
ERROR [8:5] "confirm_migros_receipts_2026" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [10:5] "preview_migros_receipts_2026" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [14:84] "get_category_month_detail" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
</diagnostics>


terminal

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


Explanation:
**Inspecting frontend tests**

I need to focus on getting the frontend tests ready, and I'll start by inspecting the actual file that contains those tests. It's important to ensure everything is working properly, so I’ll take a thorough look at the details. I want to make sure that nothing is overlooked and that the tests meet the necessary requirements! This step seems crucial to ensure everything functions as intended.


     1|import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils'
     2|import { beforeEach, describe, expect, it, vi } from 'vitest'
     3|import SidebarNav from '@/components/SidebarNav.vue'
     4|import BudgetOverviewPage from './BudgetOverviewPage.vue'
     5|import BudgetTransactionsPage from './BudgetTransactionsPage.vue'
     6|import BudgetCategoriesPage from './BudgetCategoriesPage.vue'
     7|import { getBudgetAccounts, getBudgetCategories, getBudgetOverview, getBudgetTransactions, previewBudgetTransaction, confirmBudgetTransaction, previewBudgetCategory, confirmBudgetCategory } from '@/api/budget'
     8|
     9|vi.mock('@/api/budget', () => ({
    10|  getBudgetOverview: vi.fn(),
    11|  getBudgetTransactions: vi.fn(),
    12|  getBudgetAccounts: vi.fn(),
    13|  getBudgetCategories: vi.fn(),
    14|  previewBudgetTransaction: vi.fn(),
    15|  confirmBudgetTransaction: vi.fn(),
    16|  previewBudgetTransfer: vi.fn(),
    17|  confirmBudgetTransfer: vi.fn(),
    18|  previewBudgetAccount: vi.fn(),
    19|  confirmBudgetAccount: vi.fn(),
    20|  archiveBudgetAccount: vi.fn(),
    21|  previewBudgetCategory: vi.fn(),
    22|  confirmBudgetCategory: vi.fn(),
    23|  archiveBudgetCategory: vi.fn(),
    24|  updateBudgetCategory: vi.fn(),
    25|  deleteEmptyBudgetCategory: vi.fn(),
    26|  previewBudgetCategoryMerges: vi.fn(),
    27|  confirmBudgetCategoryMerge: vi.fn(),
    28|  getBudgetTags: vi.fn(),
    29|  previewBudgetTag: vi.fn(),
    30|  confirmBudgetTag: vi.fn(),
    31|  archiveBudgetTag: vi.fn()
    32|}))
    33|
    34|beforeEach(() => {
    35|  vi.clearAllMocks()
    36|  vi.mocked(getBudgetOverview).mockResolvedValue({
    37|    current_month: '2026-05',
    38|    income_chf: '100.00',
    39|    expense_chf: '40.00',
    40|    net_cashflow_chf: '60.00',
    41|    review_count: 1,
    42|    uncategorized_count: 1,
    43|    top_categories: [{ category_name: 'Essen & Haushalt', amount_chf: '40.00' }],
    44|    recent_transactions: [{ budget_transaction_id: 'tx1', transaction_date: '2026-05-16', transaction_type: 'expense', description: 'Demo', amount_original: '40.00', currency_original: 'CHF', amount_chf: '40.00', status: 'confirmed' }]
    45|  } as any)
    46|  vi.mocked(getBudgetTransactions).mockResolvedValue([{ budget_transaction_id: 'tx1', transaction_date: '2026-05-16', transaction_type: 'expense', description: 'Demo', amount_original: '40.00', currency_original: 'CHF', amount_chf: '40.00', status: 'confirmed' }] as any)
    47|  vi.mocked(getBudgetAccounts).mockResolvedValue([{ budget_account_id: 'acc1', name: 'Haushalt', account_type: 'checking', currency: 'CHF', is_active: true }] as any)
    48|  vi.mocked(getBudgetCategories).mockResolvedValue([{ category_id: 'cat1', name: 'Essen & Haushalt', category_type: 'expense', is_active: true, children: [{ category_id: 'cat2', parent_category_id: 'cat1', name: 'Supermarkt', category_type: 'expense', is_active: false, children: [] }] }] as any)
    49|})
    50|
    51|describe('Budget Phase 1 Vue UI', () => {
    52|  it('shows Budget navigation instead of later placeholder', () => {
    53|    const wrapper = mount(SidebarNav, { global: { stubs: { RouterLink: RouterLinkStub } } })
    54|    expect(wrapper.text()).toContain('Budget')
    55|    expect(wrapper.text()).not.toContain('Budget, später')
    56|  })
    57|
    58|  it('renders Budget Overview with current month KPIs and latest transactions', async () => {
    59|    const wrapper = mount(BudgetOverviewPage)
    60|    await flushPromises()
    61|    expect(wrapper.text()).toContain('Budgetstatus nach Kategorie')
    62|    expect(wrapper.text()).toContain('Tatsächliches Netto')
    63|    expect(wrapper.text()).toContain('Review-Hinweise')
    64|    expect(wrapper.text()).toContain('Letzte Transaktionen')
    65|  })
    66|
    67|  it('renders transactions workflow and confirms via API after preview summary', async () => {
    68|    vi.mocked(previewBudgetTransaction).mockResolvedValue({ preview_id: 'p1', summary: 'Ausgabe · CHF 12.00', fx_status: 'not_needed', warnings: [], payload: {} } as any)
    69|    vi.mocked(confirmBudgetTransaction).mockResolvedValue({ status: 'confirmed', entity_id: 'tx2', audit_id: 'audit1', message: 'Budgetbuchung gespeichert' } as any)
    70|    const wrapper = mount(BudgetTransactionsPage)
    71|    await flushPromises()
    72|    expect(wrapper.text()).toContain('Effektive Ausgaben')
    73|    expect(wrapper.text()).toContain('Ausgabe erfassen')
    74|    expect(wrapper.text()).toContain('Arbeitsmodus')
    75|    await wrapper.get('[data-test="new-expense"]').trigger('click')
    76|    await wrapper.get('[data-test="transaction-account"]').setValue('acc1')
    77|    await wrapper.get('[data-test="transaction-amount"]').setValue('12.00')
    78|    await wrapper.get('[data-test="transaction-description"]').setValue('Demo Ausgabe')
    79|    await wrapper.get('[data-test="transaction-category"]').setValue('cat1')
    80|    await wrapper.get('[data-test="preview-transaction"]').trigger('click')
    81|    await flushPromises()
    82|    expect(wrapper.text()).toContain('Zusammenfassung prüfen')
    83|    await wrapper.get('[data-test="confirm-transaction"]').trigger('click')
    84|    await flushPromises()
    85|    expect(confirmBudgetTransaction).toHaveBeenCalled()
    86|    expect(wrapper.text()).toContain('Budgetbuchung gespeichert')
    87|  })
    88|
    89|  it('supports category preview confirm workflows without exposing raw IDs', async () => {
    90|    vi.mocked(previewBudgetCategory).mockResolvedValue({ preview_id: 'p-cat', summary: 'Drogerie · expense', warnings: [], payload: { name: 'Drogerie', category_type: 'expense', parent_category_id: 'cat1' } } as any)
    91|    vi.mocked(confirmBudgetCategory).mockResolvedValue({ status: 'confirmed', entity_id: 'cat3', audit_id: 'audit-cat', message: 'Kategorie gespeichert' } as any)
    92|
    93|    const wrapper = mount(BudgetCategoriesPage)
    94|    await flushPromises()
    95|
    96|    expect(wrapper.text()).toContain('Kategoriebaum verwalten')
    97|    expect(wrapper.text()).toContain('Essen & Haushalt')
    98|    expect(wrapper.text()).toContain('Supermarkt')
    99|    expect(wrapper.text()).toContain('inaktiv')
   100|    expect(wrapper.text()).toContain('Kategorie-Konsolidierungs-Preview')
   101|    expect(wrapper.text()).not.toContain('cat1')
   102|
   103|    await wrapper.get('[data-test="category-name"]').setValue('Drogerie')
   104|    await wrapper.get('[data-test="category-parent"]').setValue('cat1')
   105|    await wrapper.get('[data-test="preview-category"]').trigger('click')
   106|    await flushPromises()
   107|    expect(wrapper.text()).toContain('Zusammenfassung prüfen')
   108|    await wrapper.get('[data-test="confirm-category"]').trigger('click')
   109|    await flushPromises()
   110|    expect(confirmBudgetCategory).toHaveBeenCalled()
   111|    expect(wrapper.text()).toContain('Kategorie gespeichert')
   112|  })
   113|})
   114|


Explanation:
**Revising budget tests**

I think I need to check the mocks for getBudgetCharts since it might be missing from the current BudgetOverview imports. There seems to be a possibility that some tests are failing because of this mock issue. I should add that in to patch the tests effectively. Let’s see how I can make sure everything integrates smoothly and the tests run as expected!

patch

import { getBudgetAccounts, getBudgetCategories, getBudgetOverview, getBudgetTransactions, previewBudgetTransaction, confirmBudgetTransaction, previewBudgetCategory, confirmBudgetCategory } from '@/api/budget'

vi.mock('@/api/budget', () => ({
  getBudgetOverview: vi.fn(),
  getBudgetTransactions: vi.fn(),

import { getBudgetAccounts, getBudgetCategories, getBudgetOverview, getBudgetTransactions, previewBudgetTransaction, confirmBudgetTransaction, previewBudgetCategory, confirmBudgetCategory, getBudgetCharts, getCategoryMonthDetail } from '@/api/budget'

vi.mock('@/api/budget', () => ({
  getBudgetOverview: vi.fn(),
  getBudgetTransactions: vi.fn(),
  getBudgetCharts: vi.fn(),
  getCategoryMonthDetail: vi.fn(),


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetPhase1.test.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetPhase1.test.ts
@@ -4,11 +4,13 @@
 import BudgetOverviewPage from './BudgetOverviewPage.vue'
 import BudgetTransactionsPage from './BudgetTransactionsPage.vue'
 import BudgetCategoriesPage from './BudgetCategoriesPage.vue'
-import { getBudgetAccounts, getBudgetCategories, getBudgetOverview, getBudgetTransactions, previewBudgetTransaction, confirmBudgetTransaction, previewBudgetCategory, confirmBudgetCategory } from '@/api/budget'
+import { getBudgetAccounts, getBudgetCategories, getBudgetOverview, getBudgetTransactions, previewBudgetTransaction, confirmBudgetTransaction, previewBudgetCategory, confirmBudgetCategory, getBudgetCharts, getCategoryMonthDetail } from '@/api/budget'

 vi.mock('@/api/budget', () => ({
   getBudgetOverview: vi.fn(),
   getBudgetTransactions: vi.fn(),
+  getBudgetCharts: vi.fn(),
+  getCategoryMonthDetail: vi.fn(),
   getBudgetAccounts: vi.fn(),
   getBudgetCategories: vi.fn(),
   previewBudgetTransaction: vi.fn(),

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


  vi.mocked(getBudgetOverview).mockResolvedValue({
    current_month: '2026-05',
    income_chf: '100.00',
    expense_chf: '40.00',
    net_cashflow_chf: '60.00',
    review_count: 1,
    uncategorized_count: 1,
    budget_categories: [{
      category_id: 'cat1',
      category: 'Essen & Haushalt',
      category_type: 'expense',
      budget_month: '100.00',
      budget_year: '1200.00',
      actual_year_to_date: '140.00',
      monthly_actuals: { '01': '120.00', '02': '20.00' },
      monthly_analysis: [
        { month: '2026-01', month_label: 'Jan', actual_chf: '120.00', budget_chf: '100.00', status: 'Rot', bar_color: 'red', difference_chf: '20.00' },
        { month: '2026-02', month_label: 'Feb', actual_chf: '20.00', budget_chf: '100.00', status: 'Grün', bar_color: 'green', difference_chf: '-80.00' }
      ],
      actual_avg_month: '70.00',
      forecast_year: '840.00',
      forecast_avg_month: '70.00',
      deviation_year: '-360.00',
      deviation_month: '-30.00',
      status: 'Grün',
      months_with_bookings: 2
    }],
    top_categories: [{ category_name: 'Essen & Haushalt', amount_chf: '40.00' }],
    recent_transactions: [{ budget_transaction_id: 'tx1', transaction_date: '2026-05-16', transaction_type: 'expense', description: 'Demo', amount_original: '40.00', currency_original: 'CHF', amount_chf: '40.00', status: 'confirmed' }]
  } as any)
  vi.mocked(getBudgetCharts).mockResolvedValue({ year: '2026', empty_state: false, cashflow_by_month: [], budget_vs_actual_by_category: [], forecast_overruns: [] } as any)
  vi.mocked(getCategoryMonthDetail).mockResolvedValue({
    category_id: 'cat1', category: 'Essen & Haushalt', month: '2026-01', budget_month: '100.00', actual_chf: '120.00', difference_chf: '20.00', difference_percent: '20.00', status: 'Rot',
    breakdown_mode: 'merchant', breakdown: [{ label: 'Migros Test', amount_chf: '120.00', share_percent: '100.00' }],
    transactions: [{ budget_transaction_id: 'tx1', transaction_date: '2026-01-10', transaction_type: 'expense', description: 'Migros Bon', amount_original: '120.00', currency_original: 'CHF', amount_chf: '120.00', status: 'confirmed', category_id: 'cat1', category_name: 'Essen & Haushalt', payee: 'Migros Test', source_type: 'import_candidate', line_item_count: 2 }],
    expense_filter_url: '/planning/budget/expenses/actual?year=2026&month=2026-01&category_id=cat1'
  } as any)

  vi.mocked(getBudgetOverview).mockResolvedValue({
    current_month: '2026-05',
    income_chf: '100.00',
    expense_chf: '40.00',
    net_cashflow_chf: '60.00',
    review_count: 1,
    uncategorized_count: 1,
    top_categories: [{ category_name: 'Essen & Haushalt', amount_chf: '40.00' }],
    recent_transactions: [{ budget_transaction_id: 'tx1', transaction_date: '2026-05-16', transaction_type: 'expense', description: 'Demo', amount_original: '40.00', currency_original: 'CHF', amount_chf: '40.00', status: 'confirmed' }]
  } as any)


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetPhase1.test.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetPhase1.test.ts
@@ -42,8 +42,35 @@
     net_cashflow_chf: '60.00',
     review_count: 1,
     uncategorized_count: 1,
+    budget_categories: [{
+      category_id: 'cat1',
+      category: 'Essen & Haushalt',
+      category_type: 'expense',
+      budget_month: '100.00',
+      budget_year: '1200.00',
+      actual_year_to_date: '140.00',
+      monthly_actuals: { '01': '120.00', '02': '20.00' },
+      monthly_analysis: [
+        { month: '2026-01', month_label: 'Jan', actual_chf: '120.00', budget_chf: '100.00', status: 'Rot', bar_color: 'red', difference_chf: '20.00' },
+        { month: '2026-02', month_label: 'Feb', actual_chf: '20.00', budget_chf: '100.00', status: 'Grün', bar_color: 'green', difference_chf: '-80.00' }
+      ],
+      actual_avg_month: '70.00',
+      forecast_year: '840.00',
+      forecast_avg_month: '70.00',
+      deviation_year: '-360.00',
+      deviation_month: '-30.00',
+      status: 'Grün',
+      months_with_bookings: 2
+    }],
     top_categories: [{ category_name: 'Essen & Haushalt', amount_chf: '40.00' }],
     recent_transactions: [{ budget_transaction_id: 'tx1', transaction_date: '2026-05-16', transaction_type: 'expense', description: 'Demo', amount_original: '40.00', currency_original: 'CHF', amount_chf: '40.00', status: 'confirmed' }]
+  } as any)
+  vi.mocked(getBudgetCharts).mockResolvedValue({ year: '2026', empty_state: false, cashflow_by_month: [], budget_vs_actual_by_category: [], forecast_overruns: [] } as any)
+  vi.mocked(getCategoryMonthDetail).mockResolvedValue({
+    category_id: 'cat1', category: 'Essen & Haushalt', month: '2026-01', budget_month: '100.00', actual_chf: '120.00', difference_chf: '20.00', difference_percent: '20.00', status: 'Rot',
+    breakdown_mode: 'merchant', breakdown: [{ label: 'Migros Test', amount_chf: '120.00', share_percent: '100.00' }],
+    transactions: [{ budget_transaction_id: 'tx1', transaction_date: '2026-01-10', transaction_type: 'expense', description: 'Migros Bon', amount_original: '120.00', currency_original: 'CHF', amount_chf: '120.00', status: 'confirmed', category_id: 'cat1', category_name: 'Essen & Haushalt', payee: 'Migros Test', source_type: 'import_candidate', line_item_count: 2 }],
+    expense_filter_url: '/planning/budget/expenses/actual?year=2026&month=2026-01&category_id=cat1'
   } as any)
   vi.mocked(getBudgetTransactions).mockResolvedValue([{ budget_transaction_id: 'tx1', transaction_date: '2026-05-16', transaction_type: 'expense', description: 'Demo', amount_original: '40.00', currency_original: 'CHF', amount_chf: '40.00', status: 'confirmed' }] as any)
   vi.mocked(getBudgetAccounts).mockResolvedValue([{ budget_account_id: 'acc1', name: 'Haushalt', account_type: 'checking', currency: 'CHF', is_active: true }] as any)


  it('opens category monthly chart and chart-click month drilldown with expenses filter link', async () => {
    const wrapper = mount(BudgetOverviewPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
    await flushPromises()
    await wrapper.get('[data-test="budget-status-row"]').trigger('click')
    await flushPromises()
    expect(wrapper.find('[data-test="category-monthly-chart"]').exists()).toBe(true)
    expect(wrapper.text()).toContain('Monatsanalyse')
    expect(wrapper.text()).toContain('Budgetlinie')
    expect(wrapper.find('[data-test="month-bar-2026-01"]').classes()).toContain('bg-red-500')
    expect(wrapper.find('[data-test="month-bar-2026-02"]').classes()).toContain('bg-emerald-500')
    await wrapper.get('[data-test="month-bar-2026-01"]').trigger('click')
    await flushPromises()
    expect(getCategoryMonthDetail).toHaveBeenCalledWith('cat1', '2026-01')
    expect(wrapper.find('[data-test="month-detail-drawer"]').exists()).toBe(true)
    expect(wrapper.text()).toContain('Differenz')
    expect(wrapper.text()).toContain('20.00 %')
    expect(wrapper.find('[data-test="month-breakdown-donut"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="month-detail-table"]').text()).toContain('Migros Bon')
    expect(wrapper.find('[data-test="expenses-filter-link"]').attributes('href')).toContain('month=2026-01')
  })
})


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


    expect(confirmBudgetCategory).toHaveBeenCalled()
    expect(wrapper.text()).toContain('Kategorie gespeichert')
  })

  it('opens category monthly chart and chart-click month drilldown with expenses filter link', async () => {
    const wrapper = mount(BudgetOverviewPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
    await flushPromises()
    await wrapper.get('[data-test="budget-status-row"]').trigger('click')
    await flushPromises()
    expect(wrapper.find('[data-test="category-monthly-chart"]').exists()).toBe(true)
    expect(wrapper.text()).toContain('Monatsanalyse')
    expect(wrapper.text()).toContain('Budgetlinie')
    expect(wrapper.find('[data-test="month-bar-2026-01"]').classes()).toContain('bg-red-500')
    expect(wrapper.find('[data-test="month-bar-2026-02"]').classes()).toContain('bg-emerald-500')
    await wrapper.get('[data-test="month-bar-2026-01"]').trigger('click')
    await flushPromises()
    expect(getCategoryMonthDetail).toHaveBeenCalledWith('cat1', '2026-01')
    expect(wrapper.find('[data-test="month-detail-drawer"]').exists()).toBe(true)
    expect(wrapper.text()).toContain('Differenz')
    expect(wrapper.text()).toContain('20.00 %')
    expect(wrapper.find('[data-test="month-breakdown-donut"]').exists()).toBe(true)
    expect(wrapper.find('[data-test="month-detail-table"]').text()).toContain('Migros Bon')
    expect(wrapper.find('[data-test="expenses-filter-link"]').attributes('href')).toContain('month=2026-01')
  })
})

    expect(confirmBudgetCategory).toHaveBeenCalled()
    expect(wrapper.text()).toContain('Kategorie gespeichert')
  })
})


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetPhase1.test.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetPhase1.test.ts
@@ -139,4 +139,25 @@
     expect(confirmBudgetCategory).toHaveBeenCalled()
     expect(wrapper.text()).toContain('Kategorie gespeichert')
   })
+
+  it('opens category monthly chart and chart-click month drilldown with expenses filter link', async () => {
+    const wrapper = mount(BudgetOverviewPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
+    await flushPromises()
+    await wrapper.get('[data-test="budget-status-row"]').trigger('click')
+    await flushPromises()
+    expect(wrapper.find('[data-test="category-monthly-chart"]').exists()).toBe(true)
+    expect(wrapper.text()).toContain('Monatsanalyse')
+    expect(wrapper.text()).toContain('Budgetlinie')
+    expect(wrapper.find('[data-test="month-bar-2026-01"]').classes()).toContain('bg-red-500')
+    expect(wrapper.find('[data-test="month-bar-2026-02"]').classes()).toContain('bg-emerald-500')
+    await wrapper.get('[data-test="month-bar-2026-01"]').trigger('click')
+    await flushPromises()
+    expect(getCategoryMonthDetail).toHaveBeenCalledWith('cat1', '2026-01')
+    expect(wrapper.find('[data-test="month-detail-drawer"]').exists()).toBe(true)
+    expect(wrapper.text()).toContain('Differenz')
+    expect(wrapper.text()).toContain('20.00 %')
+    expect(wrapper.find('[data-test="month-breakdown-donut"]').exists()).toBe(true)
+    expect(wrapper.find('[data-test="month-detail-table"]').text()).toContain('Migros Bon')
+    expect(wrapper.find('[data-test="expenses-filter-link"]').attributes('href')).toContain('month=2026-01')
+  })
 })


terminal

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


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

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

stderr | src/pages/BudgetPhase1.test.ts > Budget Phase 1 Vue UI > renders transactions workflow and confirms via API after preview summary
[Vue warn]: injection "Symbol(route location)" not found.
  at <BudgetTransactionsPage ref="VTU_COMPONENT" >
  at <VTUROOT>

 ❯ src/pages/BudgetPhase1.test.ts (5 tests | 1 failed) 120ms
     ✓ shows Budget navigation instead of later placeholder 21ms
     ✓ renders Budget Overview with current month KPIs and latest transactions 27ms
     ✓ renders transactions workflow and confirms via API after preview summary 35ms
     ✓ supports category preview confirm workflows without exposing raw IDs 15ms
     × opens category monthly chart and chart-click month drilldown with expenses filter link 20ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/pages/BudgetPhase1.test.ts > Budget Phase 1 Vue UI > opens category monthly chart and chart-click month drilldown with expenses filter link
AssertionError: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ src/pages/BudgetPhase1.test.ts:148:75
    146|     await wrapper.get('[data-test="budget-status-row"]').trigger('clic…
    147|     await flushPromises()
    148|     expect(wrapper.find('[data-test="category-monthly-chart"]').exists…
       |                                                                           ^
    149|     expect(wrapper.text()).toContain('Monatsanalyse')
    150|     expect(wrapper.text()).toContain('Budgetlinie')

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

 Test Files  1 failed (1)
      Tests  1 failed | 4 passed (5)
   Start at  18:47:23
   Duration  1.27s (transform 408ms, setup 0ms, import 531ms, tests 120ms, environment 511ms)


RED bestätigt: Backend-Importe fehlen erwartungsgemäß; Frontend-Test fällt am fehlenden Monatschart. Jetzt GREEN-Implementierung minimal und sauber.

Explanation:
**Updating tasks**

I need to implement something, and it’s time to update my to-do list. I want to make sure I don’t forget any important steps. Keeping organized is essential, right? I mean, if I don’t write it down, who knows what might slip through the cracks! I’ll take a moment to jot everything down so I’m clear on what needs to be done. That way, I can check off tasks as I complete them!

todo

Arbeitsbasis prüfen: Git-Status, Budget/Migros/API/Vue-Struktur, laufende Prozesse inventarisieren

RED-Tests Backend Migros-Regel/Confirm/Audit/no-articles/no-CC-duplicate und Budgetstatus Monatsanalytics schreiben

RED-Tests Frontend Budgetstatus Row Click, Monatschart, Drilldown, Link Effektive Ausgaben schreiben

Backend implementieren: Migros 2026 Dry-Run/Autobook Service, Audit, receipt-level details, monthly analytics DTO/API


Frontend implementieren: Budgetstatus Monatsdiagramm, Drilldown, Donut, Detailtabelle, Ausgaben-Link; Migros Drawer Details

Runtime Backup + Dry Run + autorisierte produktive Migros-Receipt-Buchung 2026 aggregate-only ausführen und Audit verifizieren

Verification gates: compile, pytest, npm test/build, scans, Git-safety, diff-check

Commit, Push, Remote-Hash, Dashboard Restart/Tailscale Sanity, Abschlussbericht


patch

def seed_migros_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, auto_category_id: str | None = None, threshold_chf: str = "50.00") -> dict[str, int]:
    threshold = Decimal(threshold_chf)
    auto_category_name = _category_name(conn, auto_category_id)

def seed_migros_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, auto_category_id: str | None = None, threshold_chf: str = "50.00") -> dict[str, int]:
    _threshold = Decimal(threshold_chf)
    if not auto_category_id:
        auto_category_id, auto_category_name = _category_id_name(conn, "Essen & Haushalt")
    else:
        auto_category_name = _category_name(conn, auto_category_id)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
@@ -235,8 +235,11 @@

 def seed_migros_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, auto_category_id: str | None = None, threshold_chf: str = "50.00") -> dict[str, int]:
-    threshold = Decimal(threshold_chf)
-    auto_category_name = _category_name(conn, auto_category_id)
+    _threshold = Decimal(threshold_chf)
+    if not auto_category_id:
+        auto_category_id, auto_category_name = _category_id_name(conn, "Essen & Haushalt")
+    else:
+        auto_category_name = _category_name(conn, auto_category_id)
     grouped: dict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
     ignored = 0
     for idx, raw in enumerate(rows, start=1):

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py">
ERROR [268:33] "threshold" is not defined [reportUndefinedVariable] (Pyright)
</diagnostics>

/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.


        needs_review = auto_category_id is None
        status = "needs_review" if needs_review else "auto_categorized"
        classification = "migros_review_missing_category" if needs_review else "migros_receipt_food_household"
        review_reason = "migros_receipt_missing_food_household_category" if needs_review else "migros_receipt_auto_food_household"

        needs_review = total >= threshold or not auto_category_id
        status = "needs_review" if needs_review else "pending"
        classification = "migros_review_over_threshold" if needs_review else "migros_auto_under_threshold"
        review_reason = "migros_receipt_over_threshold" if needs_review else "migros_receipt_under_threshold_auto"


--- 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
@@ -265,10 +265,10 @@
         txn = str(first_low.get("transaktion") or first_low.get("transaktionsnummer") or first_low.get("bon") or first_low.get("bon-id") or "").strip()
         total = sum(Decimal(_decimal_text(({_normalise(k): v for k, v in raw.items()}).get("umsatz") or ({_normalise(k): v for k, v in raw.items()}).get("betrag") or ({_normalise(k): v for k, v in raw.items()}).get("amount")) or "0") for _, raw in items)
         amount = format(total, "f")
-        needs_review = total >= threshold or not auto_category_id
-        status = "needs_review" if needs_review else "pending"
-        classification = "migros_review_over_threshold" if needs_review else "migros_auto_under_threshold"
-        review_reason = "migros_receipt_over_threshold" if needs_review else "migros_receipt_under_threshold_auto"
+        needs_review = auto_category_id is None
+        status = "needs_review" if needs_review else "auto_categorized"
+        classification = "migros_review_missing_category" if needs_review else "migros_receipt_food_household"
+        review_reason = "migros_receipt_missing_food_household_category" if needs_review else "migros_receipt_auto_food_household"
         row_ref = f"BON:{tx_date}:{time}:{store}:{register}:{txn}"[:180]
         ts = now()
         candidate_id = "btxcand_" + _candidate_key(source_file_label, row_ref, tx_date, amount, "Migros Bon")


            "notes": json.dumps({"rule": "migros_receipt_food_household", "legacy_threshold_chf": threshold_chf, "line_items": len(items), "article_rows_are_detail_only": True}, ensure_ascii=False),
            "classification": classification,
            "review_reason": review_reason,
            "rule_id": "migros_receipt_food_household" if not needs_review else "migros_receipt_missing_category",
            "rule_name": "Migros Bon = Essen & Haushalt" if not needs_review else "Migros Bon Kategorie fehlt",

            "notes": json.dumps({"threshold_chf": threshold_chf, "line_items": len(items)}, ensure_ascii=False),
            "classification": classification,
            "review_reason": review_reason,
            "rule_id": "migros_under_threshold_auto" if not needs_review else "migros_over_threshold_review",


--- 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
@@ -287,10 +287,11 @@
             "confidence": "0.90" if not needs_review else "0.60",
             "requires_review": needs_review,
             "status": status,
-            "notes": json.dumps({"threshold_chf": threshold_chf, "line_items": len(items)}, ensure_ascii=False),
+            "notes": json.dumps({"rule": "migros_receipt_food_household", "legacy_threshold_chf": threshold_chf, "line_items": len(items), "article_rows_are_detail_only": True}, ensure_ascii=False),
             "classification": classification,
             "review_reason": review_reason,
-            "rule_id": "migros_under_threshold_auto" if not needs_review else "migros_over_threshold_review",
+            "rule_id": "migros_receipt_food_household" if not needs_review else "migros_receipt_missing_category",
+            "rule_name": "Migros Bon = Essen & Haushalt" if not needs_review else "Migros Bon Kategorie fehlt",
             "source_priority": 90,
             "receipt_key": key,
             "raw_fingerprint": key,


    return {"receipt_candidate_count": created, "candidate_count": created, "line_item_count": line_count, "auto_assigned_count": auto, "review_required_count": review, "ignored_count": ignored}

def preview_migros_receipts_2026(conn: Connection, *, year: str = "2026") -> dict[str, Any]:
    rows = conn.execute(
        """
        SELECT status, COUNT(*) AS count
        FROM budget_transaction_candidates
        WHERE source_type='migros_receipt' AND substr(COALESCE(transaction_date,''),1,4)=?
        GROUP BY status
        """,
        (year,),
    ).fetchall()
    by_status = {str(r["status"]): int(r["count"] or 0) for r in rows}
    receipt_total = sum(by_status.values())
    confirmed = by_status.get("confirmed", 0)
    open_count = sum(count for status, count in by_status.items() if status not in {"confirmed", "ignored", "superseded", "reference_2025", "archived_reference"})
    covered = conn.execute(
        """
        SELECT COUNT(*) FROM budget_transaction_candidates
        WHERE status='covered_by_migros' AND substr(COALESCE(transaction_date,''),1,4)=?
        """,
        (year,),
    ).fetchone()[0]
    line_items = conn.execute(
        """
        SELECT COUNT(*)
        FROM budget_import_line_items li
        JOIN budget_transaction_candidates c ON c.transaction_candidate_id=li.transaction_candidate_id
        WHERE c.source_type='migros_receipt' AND substr(COALESCE(c.transaction_date,''),1,4)=?
        """,
        (year,),
    ).fetchone()[0]
    article_candidates = conn.execute(
        """
        SELECT COUNT(*) FROM budget_transaction_candidates
        WHERE substr(COALESCE(transaction_date,''),1,4)=?
          AND (lower(COALESCE(source_file_label,'')) LIKE '%migros%' OR lower(COALESCE(source_file_label,'')) LIKE '%cumulus%')
          AND COALESCE(source_type,'') NOT IN ('migros_receipt')
          AND status NOT IN ('superseded','ignored','reference_2025','archived_reference')
        """,
        (year,),
    ).fetchone()[0]
    return {
        "year": year,
        "receipt_candidate_count": receipt_total,
        "confirmed_receipt_count": confirmed,
        "open_receipt_count": open_count,
        "covered_by_migros_count": int(covered or 0),
        "line_item_count": int(line_items or 0),
        "article_candidate_count": int(article_candidates or 0),
        "status_counts": by_status,
        "dry_run": True,
    }

def confirm_migros_receipts_2026(conn: Connection, *, account_id: str, year: str = "2026") -> dict[str, Any]:
    if not account_id:
        raise HTTPException(status_code=422, detail="account_id required")
    category_id, category_name = _category_id_name(conn, "Essen & Haushalt")
    if not category_id:
        raise HTTPException(status_code=422, detail="Essen & Haushalt category required")
    rows = conn.execute(
        """
        SELECT * FROM budget_transaction_candidates
        WHERE source_type='migros_receipt' AND substr(COALESCE(transaction_date,''),1,4)=?
          AND status NOT IN ('confirmed','ignored','covered_by_migros','superseded','reference_2025','archived_reference')
        ORDER BY transaction_date, created_at
        """,
        (year,),
    ).fetchall()
    ts = now()
    confirmed = skipped = 0
    errors: list[dict[str, str]] = []
    for row in rows:
        cid = str(row["transaction_candidate_id"])
        try:
            conn.execute(
                """
                UPDATE budget_transaction_candidates
                SET proposed_category_id=?, proposed_category_name=?, confidence='0.98', requires_review=0,
                    status='auto_categorized', classification='migros_receipt_food_household',
                    review_reason='migros_receipt_auto_food_household', rule_id='migros_receipt_food_household',
                    rule_name='Migros Bon = Essen & Haushalt', updated_at=?
                WHERE transaction_candidate_id=?
                """,
                (category_id, category_name, ts, cid),
            )
            confirm_transaction_candidate(conn, cid, {"account_id": account_id, "category_id": category_id, "budget_year": year})
            confirmed += 1
        except Exception as exc:  # keep per-candidate auditability and aggregate reporting
            skipped += 1
            errors.append({"candidate_id": cid, "error": str(getattr(exc, "detail", str(exc)))})
    audit_id = record_audit_event(
        conn,
        source="budget_phase111_migros",
        action="migros_receipts_2026_confirmed",
        entity_type="budget_transaction_candidate",
        entity_id=f"migros:{year}",
        new_values={"confirmed_count": confirmed, "skipped_count": skipped, "error_count": len(errors), "line_item_transaction_count": 0},
        created_by="agent",
    )
    conn.commit()
    return {"year": year, "confirmed_count": confirmed, "skipped_count": skipped, "errors": errors, "line_item_transaction_count": 0, "audit_id": audit_id}


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
@@ -320,6 +320,109 @@
     return {"receipt_candidate_count": created, "candidate_count": created, "line_item_count": line_count, "auto_assigned_count": auto, "review_required_count": review, "ignored_count": ignored}

+def preview_migros_receipts_2026(conn: Connection, *, year: str = "2026") -> dict[str, Any]:
+    rows = conn.execute(
+        """
+        SELECT status, COUNT(*) AS count
+        FROM budget_transaction_candidates
+        WHERE source_type='migros_receipt' AND substr(COALESCE(transaction_date,''),1,4)=?
+        GROUP BY status
+        """,
+        (year,),
+    ).fetchall()
+    by_status = {str(r["status"]): int(r["count"] or 0) for r in rows}
+    receipt_total = sum(by_status.values())
+    confirmed = by_status.get("confirmed", 0)
+    open_count = sum(count for status, count in by_status.items() if status not in {"confirmed", "ignored", "superseded", "reference_2025", "archived_reference"})
+    covered = conn.execute(
+        """
+        SELECT COUNT(*) FROM budget_transaction_candidates
+        WHERE status='covered_by_migros' AND substr(COALESCE(transaction_date,''),1,4)=?
+        """,
+        (year,),
+    ).fetchone()[0]
+    line_items = conn.execute(
+        """
+        SELECT COUNT(*)
+        FROM budget_import_line_items li
+        JOIN budget_transaction_candidates c ON c.transaction_candidate_id=li.transaction_candidate_id
+        WHERE c.source_type='migros_receipt' AND substr(COALESCE(c.transaction_date,''),1,4)=?
+        """,
+        (year,),
+    ).fetchone()[0]
+    article_candidates = conn.execute(
+        """
+        SELECT COUNT(*) FROM budget_transaction_candidates
+        WHERE substr(COALESCE(transaction_date,''),1,4)=?
+          AND (lower(COALESCE(source_file_label,'')) LIKE '%migros%' OR lower(COALESCE(source_file_label,'')) LIKE '%cumulus%')
+          AND COALESCE(source_type,'') NOT IN ('migros_receipt')
+          AND status NOT IN ('superseded','ignored','reference_2025','archived_reference')
+        """,
+        (year,),
+    ).fetchone()[0]
+    return {
+        "year": year,
+        "receipt_candidate_count": receipt_total,
+        "confirmed_receipt_count": confirmed,
+        "open_receipt_count": open_count,
+        "covered_by_migros_count": int(covered or 0),
+        "line_item_count": int(line_items or 0),
+        "article_candidate_count": int(article_candidates or 0),
+        "status_counts": by_status,
+        "dry_run": True,
+    }
+
+
+def confirm_migros_receipts_2026(conn: Connection, *, account_id: str, year: str = "2026") -> dict[str, Any]:
+    if not account_id:
+        raise HTTPException(status_code=422, detail="account_id required")
+    category_id, category_name = _category_id_name(conn, "Essen & Haushalt")
+    if not category_id:
+        raise HTTPException(status_code=422, detail="Essen & Haushalt category required")
+    rows = conn.execute(
+        """
+        SELECT * FROM budget_transaction_candidates
+        WHERE source_type='migros_receipt' AND substr(COALESCE(transaction_date,''),1,4)=?
+          AND status NOT IN ('confirmed','ignored','covered_by_migros','superseded','reference_2025','archived_reference')
+        ORDER BY transaction_date, created_at
+        """,
+        (year,),
+    ).fetchall()
+    ts = now()
+    confirmed = skipped = 0
+    errors: list[dict[str, str]] = []
+    for row in rows:
+        cid = str(row["transaction_candidate_id"])
+        try:
+            conn.execute(
+                """
+                UPDATE budget_transaction_candidates
+                SET proposed_category_id=?, proposed_category_name=?, confidence='0.98', requires_review=0,
+                    status='auto_categorized', classification='migros_receipt_food_household',
+                    review_reason='migros_receipt_auto_food_household', rule_id='migros_receipt_food_household',
+                    rule_name='Migros Bon = Essen & Haushalt', updated_at=?
+                WHERE transaction_candidate_id=?
+                """,
+                (category_id, category_name, ts, cid),
+            )
+            confirm_transaction_candidate(conn, cid, {"account_id": account_id, "category_id": category_id, "budget_year": year})
+            confirmed += 1
+        except Exception as exc:  # keep per-candidate auditability and aggregate reporting
+            skipped += 1
+            errors.append({"candidate_id": cid, "error": str(getattr(exc, "detail", str(exc)))})
+    audit_id = record_audit_event(
+        conn,
+        source="budget_phase111_migros",
+        action="migros_receipts_2026_confirmed",
+        entity_type="budget_transaction_candidate",
+        entity_id=f"migros:{year}",
+        new_values={"confirmed_count": confirmed, "skipped_count": skipped, "error_count": len(errors), "line_item_transaction_count": 0},
+        created_by="agent",
+    )
+    conn.commit()
+    return {"year": year, "confirmed_count": confirmed, "skipped_count": skipped, "errors": errors, "line_item_transaction_count": 0, "audit_id": audit_id}
+
+
 def _matches_any(text: str, patterns: list[str]) -> bool:
     hay = _normalise(text)
     return any(_normalise(p) in hay for p in patterns)


        elif source_type == "migros_receipt":
            category_id, category_name = _category_id_name(conn, "Essen & Haushalt")
            classification = "migros_receipt_food_household"
            confidence = "0.98"
            rule_id = "migros_receipt_food_household"
            rule_name = "Migros Bon = Essen & Haushalt"
            review_reason = "Migros Bon automatisch Essen & Haushalt; Artikelzeilen bleiben Detaildaten"
            status = "auto_categorized"
            requires_review = 0
            counts["migros_under_50_count"] += 1

        elif source_type == "migros_receipt":
            amount = Decimal(str(row["amount_original"] or "0"))
            category_id, category_name = _category_id_name(conn, "Essen & Haushalt")
            classification = "migros_auto_under_threshold" if amount < Decimal("50") else "migros_review_over_threshold"
            confidence = "0.90" if amount < Decimal("50") else "0.62"
            rule_id = "p16_migros_receipt_under_50" if amount < Decimal("50") else "p16_migros_receipt_over_50"
            rule_name = "Migros Bon unter 50 CHF" if amount < Decimal("50") else "Migros Bon ab 50 CHF"
            review_reason = "Migros Bon unter 50 CHF automatisch Essen & Haushalt" if amount < Decimal("50") else "Migros Bon ab 50 CHF bitte prüfen oder splitten"
            status = "auto_categorized" if amount < Decimal("50") else "needs_review"
            requires_review = 0 if amount < Decimal("50") else 1
            counts["migros_under_50_count" if amount < Decimal("50") else "migros_over_50_count"] += 1


--- 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
@@ -500,16 +500,15 @@
             review_reason = "Migros-CSV ist primäre Quelle für Migros-Ausgaben"; rule_id = "p16_card_migros_covered"; rule_name = "Kreditkarten-Migros durch Migros-CSV abgedeckt"; covered_by = "migros_csv"
             counts["covered_by_migros_count"] += 1
         elif source_type == "migros_receipt":
-            amount = Decimal(str(row["amount_original"] or "0"))
             category_id, category_name = _category_id_name(conn, "Essen & Haushalt")
-            classification = "migros_auto_under_threshold" if amount < Decimal("50") else "migros_review_over_threshold"
-            confidence = "0.90" if amount < Decimal("50") else "0.62"
-            rule_id = "p16_migros_receipt_under_50" if amount < Decimal("50") else "p16_migros_receipt_over_50"
-            rule_name = "Migros Bon unter 50 CHF" if amount < Decimal("50") else "Migros Bon ab 50 CHF"
-            review_reason = "Migros Bon unter 50 CHF automatisch Essen & Haushalt" if amount < Decimal("50") else "Migros Bon ab 50 CHF bitte prüfen oder splitten"
-            status = "auto_categorized" if amount < Decimal("50") else "needs_review"
-            requires_review = 0 if amount < Decimal("50") else 1
-            counts["migros_under_50_count" if amount < Decimal("50") else "migros_over_50_count"] += 1
+            classification = "migros_receipt_food_household"
+            confidence = "0.98"
+            rule_id = "migros_receipt_food_household"
+            rule_name = "Migros Bon = Essen & Haushalt"
+            review_reason = "Migros Bon automatisch Essen & Haushalt; Artikelzeilen bleiben Detaildaten"
+            status = "auto_categorized"
+            requires_review = 0
+            counts["migros_under_50_count"] += 1
         else:
             rule = _phase16_match(desc)
             if rule:


            OR classification IN ('migros_auto_under_threshold','migros_receipt_food_household')
            OR classification='auto_transport'

            OR (classification='migros_auto_under_threshold' AND CAST(COALESCE(amount_original, '999999') AS REAL) < 50)
            OR classification='auto_transport'


--- 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
@@ -868,7 +868,7 @@
         AND COALESCE(covered_by_source, '')=''
         AND (
             classification='subscription_auto'
-            OR (classification='migros_auto_under_threshold' AND CAST(COALESCE(amount_original, '999999') AS REAL) < 50)
+            OR classification IN ('migros_auto_under_threshold','migros_receipt_food_household')
             OR classification='auto_transport'
         )
         AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%galaxus%'


Explanation:
**Assessing budget needs**

I realize I need to create a budget overview. I'll add a helper for month analysis and provide details. Additionally, I want to include a budget status with the monthly analysis as well. I should capture the line item count for transactions too. There might be a way to detail this custom; I need to track percentages and implement these after confirming the status. I also want to make sure I check monthly status for both expenses and income.

def _status(category_type: str, budget_year: Decimal, forecast_year: Decimal, actual_year: Decimal, months_with_bookings: int) -> str:
    if budget_year <= 0 and actual_year > 0:
        return "ohne Budget"
    if actual_year == 0 or months_with_bookings == 0 or budget_year <= 0:
        return "Noch keine Daten"
    ratio = forecast_year / budget_year
    if category_type == "income":
        if ratio >= Decimal("1"):
            return "Grün"
        if ratio >= Decimal("0.90"):
            return "Gelb"
        return "Rot"
    if ratio > Decimal("1.15"):
        return "Kritisch"
    if ratio > Decimal("1"):
        return "Rot"
    if ratio >= Decimal("0.90"):
        return "Gelb"
    return "Grün"

def _month_status(category_type: str, actual: Decimal, budget: Decimal) -> tuple[str, str]:
    if budget <= 0 and actual > 0:
        return "ohne Budget", "slate"
    if budget <= 0 or actual == 0:
        return "Noch keine Daten", "slate"
    ratio = actual / budget
    if category_type == "income":
        if ratio >= Decimal("1"):
            return "Grün", "green"
        if ratio >= Decimal("0.90"):
            return "Gelb", "yellow"
        return "Rot", "red"
    if ratio > Decimal("1"):
        return "Rot", "red"
    if ratio >= Decimal("0.90"):
        return "Gelb", "yellow"
    return "Grün", "green"

def _monthly_analysis(category_type: str, year: str, budget_month: Decimal, monthly: dict[str, Decimal]) -> list[dict]:
    labels = ["Jan", "Feb", "Mrz", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
    out = []
    for idx, label in enumerate(labels, start=1):
        key = f"{idx:02d}"
        actual = monthly.get(key, Decimal("0"))
        status, color = _month_status(category_type, actual, budget_month)
        out.append({
            "month": f"{year}-{key}",
            "month_label": label,
            "actual_chf": _fmt(actual),
            "budget_chf": _fmt(budget_month),
            "difference_chf": _fmt(actual - budget_month),
            "status": status,
            "bar_color": color,
        })
    return out


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_overview.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_overview.py
@@ -130,6 +130,44 @@
     if ratio >= Decimal("0.90"):
         return "Gelb"
     return "Grün"
+
+
+def _month_status(category_type: str, actual: Decimal, budget: Decimal) -> tuple[str, str]:
+    if budget <= 0 and actual > 0:
+        return "ohne Budget", "slate"
+    if budget <= 0 or actual == 0:
+        return "Noch keine Daten", "slate"
+    ratio = actual / budget
+    if category_type == "income":
+        if ratio >= Decimal("1"):
+            return "Grün", "green"
+        if ratio >= Decimal("0.90"):
+            return "Gelb", "yellow"
+        return "Rot", "red"
+    if ratio > Decimal("1"):
+        return "Rot", "red"
+    if ratio >= Decimal("0.90"):
+        return "Gelb", "yellow"
+    return "Grün", "green"
+
+
+def _monthly_analysis(category_type: str, year: str, budget_month: Decimal, monthly: dict[str, Decimal]) -> list[dict]:
+    labels = ["Jan", "Feb", "Mrz", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
+    out = []
+    for idx, label in enumerate(labels, start=1):
+        key = f"{idx:02d}"
+        actual = monthly.get(key, Decimal("0"))
+        status, color = _month_status(category_type, actual, budget_month)
+        out.append({
+            "month": f"{year}-{key}",
+            "month_label": label,
+            "actual_chf": _fmt(actual),
+            "budget_chf": _fmt(budget_month),
+            "difference_chf": _fmt(actual - budget_month),
+            "status": status,
+            "bar_color": color,
+        })
+    return out

 def get_budget_status_by_category(conn: Connection, *, year: str = "2026") -> list[dict]:

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


        category_monthly = monthly_actuals.get(category_id, {})
        results.append({
            "category_id": category_id,


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_overview.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_overview.py
@@ -249,6 +249,7 @@
         forecast_month = forecast_year / Decimal("12") if forecast_year else Decimal("0")
         deviation = forecast_year - item["budget_year"]
         status = _status(item["category_type"], item["budget_year"], forecast_year, actual_year, months)
+        category_monthly = monthly_actuals.get(category_id, {})
         results.append({
             "category_id": category_id,
             "category": item["category"],


            "monthly_actuals": {f"{m:02d}": _fmt(category_monthly.get(f"{m:02d}", Decimal("0"))) for m in range(1, 13)},
            "monthly_analysis": _monthly_analysis(item["category_type"], year, item["budget_month"] if item["has_plan"] else Decimal("0"), category_monthly),

"monthly_actuals": {f"{m:02d}": _fmt(monthly_actuals.get(category_id, {}).get(f"{m:02d}", Decimal("0"))) for m in range(1, 13)},


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_overview.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_overview.py
@@ -258,7 +258,8 @@
             "budget_year": _fmt(item["budget_year"]) if item["has_plan"] else None,
             "has_budget": bool(item["has_plan"]),
             "actual_year_to_date": _fmt(actual_year),
-            "monthly_actuals": {f"{m:02d}": _fmt(monthly_actuals.get(category_id, {}).get(f"{m:02d}", Decimal("0"))) for m in range(1, 13)},
+            "monthly_actuals": {f"{m:02d}": _fmt(category_monthly.get(f"{m:02d}", Decimal("0"))) for m in range(1, 13)},
+            "monthly_analysis": _monthly_analysis(item["category_type"], year, item["budget_month"] if item["has_plan"] else Decimal("0"), category_monthly),
             "actual_avg_month": _fmt(actual_avg),
             "forecast_year": _fmt(forecast_year),
             "forecast_avg_month": _fmt(forecast_month),


Explanation:
**Planning query structure**

I need to get the category month details. It might be a good idea to add that step before getting the budget charts. I’m thinking through how to structure the query effectively to ensure that all necessary details are captured. I’ll need to double-check that everything flows correctly so that getting the budget charts afterward pulls the right information. Let’s make sure this setup is logical and efficient!

    results.sort(key=lambda r: (0 if r["category_type"] == "income" else 1, meta.get(r["category_id"], {}).get("sort_order") or 999, r["category"]))
    return results

def get_category_month_detail(conn: Connection, *, category_id: str, month: str) -> dict:
    if not category_id or not month or len(month) < 7:
        raise ValueError("category_id and month YYYY-MM are required")
    cat = conn.execute("SELECT category_id, name, category_type FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
    if not cat:
        raise ValueError("category not found")
    year = month[:4]
    plan_rows = conn.execute(
        """
        SELECT * FROM budget_plan_items
        WHERE is_active=1 AND category_id=? AND substr(plan_month,1,4)=?
        """,
        (category_id, year),
    ).fetchall()
    budget_month = sum((_monthly_plan_amount(p) for p in plan_rows), Decimal("0"))
    tx_rows = conn.execute(
        """
        SELECT t.*, a.name AS account_name, c.name AS category_name,
               COALESCE(li.line_item_count, 0) AS line_item_count
        FROM budget_transactions t
        JOIN budget_accounts a ON a.budget_account_id=t.account_id
        LEFT JOIN budget_categories c ON c.category_id=t.category_id
        LEFT JOIN (
            SELECT transaction_candidate_id, COUNT(*) AS line_item_count
            FROM budget_import_line_items
            GROUP BY transaction_candidate_id
        ) li ON li.transaction_candidate_id=t.source_candidate_id
        WHERE t.status='confirmed' AND t.category_id=? AND substr(t.transaction_date,1,7)=?
          AND t.transaction_type IN ('expense','fee','income','refund')
        ORDER BY t.transaction_date, t.created_at
        """,
        (category_id, month),
    ).fetchall()
    actual = sum((Decimal(str(r["amount_chf"] or r["amount_original"] or "0")) for r in tx_rows), Decimal("0"))
    difference = actual - budget_month
    diff_pct = Decimal("0") if budget_month == 0 else (difference / budget_month * Decimal("100"))
    status, color = _month_status(str(cat["category_type"]), actual, budget_month)
    grouped: dict[str, Decimal] = {}
    for row in tx_rows:
        label = str(row["payee"] or row["account_name"] or row["source_type"] or "Quelle")
        grouped[label] = grouped.get(label, Decimal("0")) + Decimal(str(row["amount_chf"] or row["amount_original"] or "0"))
    breakdown = []
    for label, value in sorted(grouped.items(), key=lambda kv: kv[1], reverse=True):
        share = Decimal("0") if actual == 0 else (value / actual * Decimal("100"))
        breakdown.append({"label": label, "amount_chf": _fmt(value), "share_percent": _fmt(share)})
    transactions = []
    for row in tx_rows:
        item = row_to_dict(row)
        item["line_item_count"] = int(row["line_item_count"] or 0)
        item["article_rows_detail_only"] = bool(item["line_item_count"])
        transactions.append(item)
    return {
        "category_id": category_id,
        "category": str(cat["name"]),
        "category_type": str(cat["category_type"]),
        "month": month,
        "budget_month": _fmt(budget_month),
        "actual_chf": _fmt(actual),
        "difference_chf": _fmt(difference),
        "difference_percent": _fmt(diff_pct),
        "status": status,
        "bar_color": color,
        "breakdown_mode": "merchant" if any(r["payee"] for r in tx_rows) else "source",
        "breakdown": breakdown,
        "transactions": transactions,
        "expense_filter_url": f"/planning/budget/expenses/actual?year={year}&month={month}&category_id={category_id}",
    }

def get_budget_charts_2026(conn: Connection, *, year: str = "2026") -> dict:

    results.sort(key=lambda r: (0 if r["category_type"] == "income" else 1, meta.get(r["category_id"], {}).get("sort_order") or 999, r["category"]))
    return results

def get_budget_charts_2026(conn: Connection, *, year: str = "2026") -> dict: