@router.get("/grocery-optimizer/dashboard")
def grocery_optimizer_dashboard(conn: Connection = Depends(get_db)):
    return get_grocery_optimizer_dashboard(conn)

@router.get("/grocery-optimizer/provider-strategy")
def grocery_optimizer_provider_strategy():
    return get_grocery_provider_strategy()

@router.get("/grocery-optimizer/rappn-link")
def grocery_optimizer_rappn_link(product_name: str):
    return build_rappn_link_out(product_name)


patch

--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -162,6 +162,16 @@
     return get_grocery_optimizer_dashboard(conn)

+@router.get("/grocery-optimizer/provider-strategy")
+def grocery_optimizer_provider_strategy():
+    return get_grocery_provider_strategy()
+
+
+@router.get("/grocery-optimizer/rappn-link")
+def grocery_optimizer_rappn_link(product_name: str):
+    return build_rappn_link_out(product_name)
+
+
 @router.post("/grocery-optimizer/demo-receipt")
 def grocery_optimizer_demo_receipt(payload: dict, conn: Connection = Depends(get_db)):
     return add_demo_migros_receipt(conn, purchase_date=payload.get("purchase_date") or "2026-05-19", store_name=payload.get("store_name") or "Migros", items=payload.get("items") or [])


@router.post("/grocery-optimizer/manual-mapping/preview")
def grocery_optimizer_manual_mapping_preview(payload: dict, conn: Connection = Depends(get_db)):
    return preview_manual_grocery_mapping(conn, product_item_id=payload["product_item_id"], target_retailer=payload["target_retailer"], target_product_name=payload["target_product_name"], target_product_url=payload["target_product_url"], candidate_price_text=payload.get("candidate_price_text"), candidate_unit_price_text=payload.get("candidate_unit_price_text"), target_package_size=payload.get("target_package_size"), target_unit=payload.get("target_unit"), target_price_date=payload.get("target_price_date"), target_price_source=payload.get("target_price_source"), user_note=payload.get("user_note"))

@router.post("/grocery-optimizer/manual-mapping/confirm")
def grocery_optimizer_manual_mapping_confirm(payload: dict, conn: Connection = Depends(get_db)):
    return confirm_manual_grocery_mapping(conn, payload)

@router.post("/grocery-optimizer/manual-mapping")
def grocery_optimizer_manual_mapping(payload: dict, conn: Connection = Depends(get_db)):
    return create_manual_grocery_mapping(conn, product_item_id=payload["product_item_id"], target_retailer=payload["target_retailer"], target_product_name=payload["target_product_name"], target_product_url=payload["target_product_url"], target_brand=payload.get("target_brand"), target_package_size=payload.get("target_package_size"), target_unit=payload.get("target_unit"), target_price_date=payload.get("target_price_date"), target_price_source=payload.get("target_price_source"), match_type=payload.get("match_type") or "manual_match", candidate_price_text=payload.get("candidate_price_text"), candidate_unit_price_text=payload.get("candidate_unit_price_text"), user_note=payload.get("user_note"))

@router.post("/grocery-optimizer/manual-mapping")
def grocery_optimizer_manual_mapping(payload: dict, conn: Connection = Depends(get_db)):
    return create_manual_grocery_mapping(conn, product_item_id=payload["product_item_id"], target_retailer=payload["target_retailer"], target_product_name=payload["target_product_name"], target_product_url=payload["target_product_url"], target_brand=payload.get("target_brand"), target_package_size=payload.get("target_package_size"), target_unit=payload.get("target_unit"), match_type=payload.get("match_type") or "manual_match", candidate_price_text=payload.get("candidate_price_text"), candidate_unit_price_text=payload.get("candidate_unit_price_text"), user_note=payload.get("user_note"))


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -193,9 +193,19 @@
     return reject_grocery_product_match(conn, match_id, user_note=(payload or {}).get("user_note"))

+@router.post("/grocery-optimizer/manual-mapping/preview")
+def grocery_optimizer_manual_mapping_preview(payload: dict, conn: Connection = Depends(get_db)):
+    return preview_manual_grocery_mapping(conn, product_item_id=payload["product_item_id"], target_retailer=payload["target_retailer"], target_product_name=payload["target_product_name"], target_product_url=payload["target_product_url"], candidate_price_text=payload.get("candidate_price_text"), candidate_unit_price_text=payload.get("candidate_unit_price_text"), target_package_size=payload.get("target_package_size"), target_unit=payload.get("target_unit"), target_price_date=payload.get("target_price_date"), target_price_source=payload.get("target_price_source"), user_note=payload.get("user_note"))
+
+
+@router.post("/grocery-optimizer/manual-mapping/confirm")
+def grocery_optimizer_manual_mapping_confirm(payload: dict, conn: Connection = Depends(get_db)):
+    return confirm_manual_grocery_mapping(conn, payload)
+
+
 @router.post("/grocery-optimizer/manual-mapping")
 def grocery_optimizer_manual_mapping(payload: dict, conn: Connection = Depends(get_db)):
-    return create_manual_grocery_mapping(conn, product_item_id=payload["product_item_id"], target_retailer=payload["target_retailer"], target_product_name=payload["target_product_name"], target_product_url=payload["target_product_url"], target_brand=payload.get("target_brand"), target_package_size=payload.get("target_package_size"), target_unit=payload.get("target_unit"), match_type=payload.get("match_type") or "manual_match", candidate_price_text=payload.get("candidate_price_text"), candidate_unit_price_text=payload.get("candidate_unit_price_text"), user_note=payload.get("user_note"))
+    return create_manual_grocery_mapping(conn, product_item_id=payload["product_item_id"], target_retailer=payload["target_retailer"], target_product_name=payload["target_product_name"], target_product_url=payload["target_product_url"], target_brand=payload.get("target_brand"), target_package_size=payload.get("target_package_size"), target_unit=payload.get("target_unit"), target_price_date=payload.get("target_price_date"), target_price_source=payload.get("target_price_source"), match_type=payload.get("match_type") or "manual_match", candidate_price_text=payload.get("candidate_price_text"), candidate_unit_price_text=payload.get("candidate_unit_price_text"), user_note=payload.get("user_note"))

 @router.post("/grocery-optimizer/mappings/{mapping_id}/refresh-price")


Explanation:
**Implementing price retrieval**

I need to get the opening prices for grocery items. It seems like I can implement this in the grocery_price_providers section using a specific class or function. I wonder if I need to import urllib for parsing? It looks like I should already have that covered. I'll also need to add a Request to actually retrieve the data. Let's make sure everything is set up correctly before moving on!

from urllib.parse import quote_plus, urlencode, urljoin

from urllib.parse import quote_plus, urljoin


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
@@ -9,7 +9,7 @@
 from decimal import Decimal, InvalidOperation
 from sqlite3 import Connection
 from typing import Any, Callable
-from urllib.parse import quote_plus, urljoin
+from urllib.parse import quote_plus, urlencode, urljoin
 from urllib.request import Request, urlopen

 from jarvis_finance.services.budget_common import new_id, row_to_dict

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


def search_open_prices_spike(query: str | None = None, barcode: str | None = None, *, fetcher: Callable[[str], str] | None = None, size: int = 5) -> dict[str, Any]:
    """Low-volume explicit Open Prices spike. Returns candidates only; never marks secure savings by itself."""
    params: dict[str, Any] = {"size": min(max(int(size or 5), 1), 10)}
    if barcode:
        params["code"] = barcode
    if query:
        params["product_name__like"] = query
    url = "https://prices.openfoodfacts.org/api/v1/prices?" + urlencode(params)
    fetch = fetcher
    if fetch is None:
        def fetch(target: str) -> str:  # type: ignore[no-redef]
            req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
            with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                return response.read().decode("utf-8", errors="ignore")
    try:
        payload = json.loads(fetch(url))
    except Exception as exc:
        return {"source": "open_prices", "official_api": True, "url": url, "candidates": [], "status": "needs_review", "error": type(exc).__name__}
    candidates = []
    for item in payload.get("items", [])[: params["size"]]:
        product = item.get("product") or {}
        location = item.get("location") or {}
        proof = item.get("proof") or {}
        price = item.get("price") or item.get("price_is_discounted")
        date = item.get("date") or proof.get("date")
        source_url = f"https://prices.openfoodfacts.org/prices/{item.get('id')}" if item.get("id") else "https://prices.openfoodfacts.org"
        flags = ["needs_review"]
        if not price:
            flags.append("no_price")
        if not (product.get("product_quantity") or product.get("quantity")):
            flags.append("different_pack_size")
        candidates.append({"provider": "Open Prices", "product_name": product.get("product_name") or query or barcode, "barcode": product.get("code") or barcode, "retailer": location.get("osm_name") or location.get("osm_brand") or "unknown", "price_text": _decimal_text(price), "currency": item.get("currency") or proof.get("currency"), "price_date": date, "source_url": source_url, "quality_flags": flags, "status": "needs_review", "can_count_as_secure_saving": False, "attribution": "Open Prices / Open Food Facts contributors"})
    return {"source": "open_prices", "official_api": True, "url": url, "license_note": "Open Prices/Open Food Facts attribution required", "candidates": candidates, "status": "ok"}

def provider_for_retailer(retailer: str) -> GroceryProductProvider:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
@@ -242,6 +242,41 @@
 class OttosProvider(GroceryProductProvider):
     retailer = "Otto's"
     future_status = "skeleton_provider_not_live"
+
+
+def search_open_prices_spike(query: str | None = None, barcode: str | None = None, *, fetcher: Callable[[str], str] | None = None, size: int = 5) -> dict[str, Any]:
+    """Low-volume explicit Open Prices spike. Returns candidates only; never marks secure savings by itself."""
+    params: dict[str, Any] = {"size": min(max(int(size or 5), 1), 10)}
+    if barcode:
+        params["code"] = barcode
+    if query:
+        params["product_name__like"] = query
+    url = "https://prices.openfoodfacts.org/api/v1/prices?" + urlencode(params)
+    fetch = fetcher
+    if fetch is None:
+        def fetch(target: str) -> str:  # type: ignore[no-redef]
+            req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
+            with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
+                return response.read().decode("utf-8", errors="ignore")
+    try:
+        payload = json.loads(fetch(url))
+    except Exception as exc:
+        return {"source": "open_prices", "official_api": True, "url": url, "candidates": [], "status": "needs_review", "error": type(exc).__name__}
+    candidates = []
+    for item in payload.get("items", [])[: params["size"]]:
+        product = item.get("product") or {}
+        location = item.get("location") or {}
+        proof = item.get("proof") or {}
+        price = item.get("price") or item.get("price_is_discounted")
+        date = item.get("date") or proof.get("date")
+        source_url = f"https://prices.openfoodfacts.org/prices/{item.get('id')}" if item.get("id") else "https://prices.openfoodfacts.org"
+        flags = ["needs_review"]
+        if not price:
+            flags.append("no_price")
+        if not (product.get("product_quantity") or product.get("quantity")):
+            flags.append("different_pack_size")
+        candidates.append({"provider": "Open Prices", "product_name": product.get("product_name") or query or barcode, "barcode": product.get("code") or barcode, "retailer": location.get("osm_name") or location.get("osm_brand") or "unknown", "price_text": _decimal_text(price), "currency": item.get("currency") or proof.get("currency"), "price_date": date, "source_url": source_url, "quality_flags": flags, "status": "needs_review", "can_count_as_secure_saving": False, "attribution": "Open Prices / Open Food Facts contributors"})
+    return {"source": "open_prices", "official_api": True, "url": url, "license_note": "Open Prices/Open Food Facts attribution required", "candidates": candidates, "status": "ok"}

 def provider_for_retailer(retailer: str) -> GroceryProductProvider:

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py">
ERROR [255:13] Type "((str) -> str) | None" is not assignable to declared type "(target: str) -> str"
  Type "((str) -> str) | None" is not assignable to type "(target: str) -> str"
    Type "None" is not assignable to type "(target: str) -> str" [reportAssignmentType] (Pyright)
</diagnostics>


    if fetcher is None:
        def fetch(target: str) -> str:
            req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
            with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                return response.read().decode("utf-8", errors="ignore")
    else:
        fetch = fetcher
    try:

    fetch = fetcher
    if fetch is None:
        def fetch(target: str) -> str:  # type: ignore[no-redef]
            req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
            with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                return response.read().decode("utf-8", errors="ignore")
    try:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
@@ -252,12 +252,13 @@
     if query:
         params["product_name__like"] = query
     url = "https://prices.openfoodfacts.org/api/v1/prices?" + urlencode(params)
-    fetch = fetcher
-    if fetch is None:
-        def fetch(target: str) -> str:  # type: ignore[no-redef]
+    if fetcher is None:
+        def fetch(target: str) -> str:
             req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
             with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                 return response.read().decode("utf-8", errors="ignore")
+    else:
+        fetch = fetcher
     try:
         payload = json.loads(fetch(url))
     except Exception as exc:

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py">
ERROR [261:17] Type "(str) -> str" is not assignable to declared type "(target: str) -> str"
  Type "(str) -> str" is not assignable to type "(target: str) -> str"
    Missing keyword parameter "target"
      Position-only parameter mismatch; parameter "target" is not position-only
      Position-only parameter mismatch; expected 1 but received 0 [reportAssignmentType] (Pyright)
</diagnostics>


    fetch: Callable[[str], str]
    if fetcher is None:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
@@ -252,6 +252,7 @@
     if query:
         params["product_name__like"] = query
     url = "https://prices.openfoodfacts.org/api/v1/prices?" + urlencode(params)
+    fetch: Callable[[str], str]
     if fetcher is None:
         def fetch(target: str) -> str:
             req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py">
ERROR [255:5] Declaration "fetch" is obscured by a declaration of the same name [reportRedeclaration] (Pyright)
</diagnostics>


    fetch_impl: Callable[[str], str]
    if fetcher is None:
        def fetch_impl(target: str) -> str:
            req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
            with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                return response.read().decode("utf-8", errors="ignore")
    else:
        fetch_impl = fetcher
    try:
        payload = json.loads(fetch_impl(url))

    fetch: Callable[[str], str]
    if fetcher is None:
        def fetch(target: str) -> str:
            req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
            with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                return response.read().decode("utf-8", errors="ignore")
    else:
        fetch = fetcher
    try:
        payload = json.loads(fetch(url))


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
@@ -252,16 +252,16 @@
     if query:
         params["product_name__like"] = query
     url = "https://prices.openfoodfacts.org/api/v1/prices?" + urlencode(params)
-    fetch: Callable[[str], str]
+    fetch_impl: Callable[[str], str]
     if fetcher is None:
-        def fetch(target: str) -> str:
+        def fetch_impl(target: str) -> str:
             req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
             with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                 return response.read().decode("utf-8", errors="ignore")
     else:
-        fetch = fetcher
+        fetch_impl = fetcher
     try:
-        payload = json.loads(fetch(url))
+        payload = json.loads(fetch_impl(url))
     except Exception as exc:
         return {"source": "open_prices", "official_api": True, "url": url, "candidates": [], "status": "needs_review", "error": type(exc).__name__}
     candidates = []

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py">
ERROR [262:22] Type "(str) -> str" is not assignable to declared type "(target: str) -> str"
  Type "(str) -> str" is not assignable to type "(target: str) -> str"
    Missing keyword parameter "target"
      Position-only parameter mismatch; parameter "target" is not position-only
      Position-only parameter mismatch; expected 1 but received 0 [reportAssignmentType] (Pyright)
ERROR [255:5] Declaration "fetch_impl" is obscured by a declaration of the same name [reportRedeclaration] (Pyright)
</diagnostics>


    if fetcher is None:
        def default_fetch_impl(target: str) -> str:
            req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
            with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                return response.read().decode("utf-8", errors="ignore")
        fetch_impl = default_fetch_impl
    else:

    if fetcher is None:
        def fetch_impl(target: str) -> str:
            req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
            with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                return response.read().decode("utf-8", errors="ignore")
    else:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_price_providers.py
@@ -254,10 +254,11 @@
     url = "https://prices.openfoodfacts.org/api/v1/prices?" + urlencode(params)
     fetch_impl: Callable[[str], str]
     if fetcher is None:
-        def fetch_impl(target: str) -> str:
+        def default_fetch_impl(target: str) -> str:
             req = Request(target, headers={"User-Agent": "JarvisFinanceOpenPricesSpike/1.0 explicit-user-action"})
             with urlopen(req, timeout=8) as response:  # noqa: S310 - official public API, explicit user action
                 return response.read().decode("utf-8", errors="ignore")
+        fetch_impl = default_fetch_impl
     else:
         fetch_impl = fetcher
     try:


from jarvis_finance.services.grocery_price_providers import provider_for_retailer, search_and_store_product_matches, search_open_prices_spike


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -114,7 +114,7 @@
     run_grocery_optimization,
     upsert_product_match,
 )
-from jarvis_finance.services.grocery_price_providers import provider_for_retailer, search_and_store_product_matches
+from jarvis_finance.services.grocery_price_providers import provider_for_retailer, search_and_store_product_matches, search_open_prices_spike
 from jarvis_finance.services.budget_plans import (
     archive_budget_plan_item,
     confirm_budget_plan_item,


@router.post("/grocery-optimizer/open-prices-spike")
def grocery_optimizer_open_prices_spike(payload: dict):
    return search_open_prices_spike(query=payload.get("query"), barcode=payload.get("barcode"), size=int(payload.get("size") or 5))

@router.post("/grocery-optimizer/demo-receipt")


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -172,6 +172,11 @@
     return build_rappn_link_out(product_name)

+@router.post("/grocery-optimizer/open-prices-spike")
+def grocery_optimizer_open_prices_spike(payload: dict):
+    return search_open_prices_spike(query=payload.get("query"), barcode=payload.get("barcode"), size=int(payload.get("size") or 5))
+
+
 @router.post("/grocery-optimizer/demo-receipt")
 def grocery_optimizer_demo_receipt(payload: dict, conn: Connection = Depends(get_db)):
     return add_demo_migros_receipt(conn, purchase_date=payload.get("purchase_date") or "2026-05-19", store_name=payload.get("store_name") or "Migros", items=payload.get("items") or [])


    get_grocery_provider_strategy,
    build_rappn_link_out,
    preview_manual_grocery_mapping,
    confirm_manual_grocery_mapping,
)
from jarvis_finance.services.grocery_price_providers import search_and_store_product_matches, search_open_prices_spike, CoopProvider, GroceryProductProvider, GroceryProviderResult

    generate_grocery_optimization_report,
)
from jarvis_finance.services.grocery_price_providers import search_and_store_product_matches, CoopProvider, GroceryProductProvider, GroceryProviderResult


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_grocery_matching_learning_v2.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_grocery_matching_learning_v2.py
@@ -14,9 +14,12 @@
     refresh_grocery_mapping_price,
     get_grocery_optimizer_dashboard,
     run_grocery_optimization,
-    generate_grocery_optimization_report,
+    get_grocery_provider_strategy,
+    build_rappn_link_out,
+    preview_manual_grocery_mapping,
+    confirm_manual_grocery_mapping,
 )
-from jarvis_finance.services.grocery_price_providers import search_and_store_product_matches, CoopProvider, GroceryProductProvider, GroceryProviderResult
+from jarvis_finance.services.grocery_price_providers import search_and_store_product_matches, search_open_prices_spike, CoopProvider, GroceryProductProvider, GroceryProviderResult

 def db() -> sqlite3.Connection:

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/tests/unit/test_grocery_matching_learning_v2.py">
ERROR [125:14] "generate_grocery_optimization_report" is not defined [reportUndefinedVariable] (Pyright)
</diagnostics>


    run_grocery_optimization,
    generate_grocery_optimization_report,
    get_grocery_provider_strategy,

    run_grocery_optimization,
    get_grocery_provider_strategy,


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_grocery_matching_learning_v2.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_grocery_matching_learning_v2.py
@@ -14,6 +14,7 @@
     refresh_grocery_mapping_price,
     get_grocery_optimizer_dashboard,
     run_grocery_optimization,
+    generate_grocery_optimization_report,
     get_grocery_provider_strategy,
     build_rappn_link_out,
     preview_manual_grocery_mapping,


def test_provider_strategy_documents_rappn_linkout_and_llm_limits() -> None:
    strategy = get_grocery_provider_strategy()
    rappn = next(row for row in strategy['provider_matrix'] if row['provider'] == 'Rappn.ch')
    assert rappn['official_api'] is False
    assert 'Link-Out' in rappn['recommendation'] or 'Link-Out' in strategy['rappn']['mode']
    assert strategy['rappn']['mode'] == 'link_out_only'
    assert any('Preise erfinden' in item for item in strategy['llm_policy']['forbidden'])
    assert any('Produktnamen normalisieren' in item for item in strategy['llm_policy']['allowed'])
    link = build_rappn_link_out('Milch 1L')
    assert link['mode'] == 'link_out_only'
    assert link['manual_source'] == 'manual/rappn_user_checked'
    assert 'api.rappn' not in link['url']

def test_open_prices_without_price_is_never_secure() -> None:
    payload = {'items': [{'id': 7, 'product': {'code': '123', 'product_name': 'Milch', 'product_quantity': '1'}, 'location': {'osm_name': 'Test Shop'}, 'proof': {'date': '2026-05-19', 'currency': 'CHF'}}]}
    result = search_open_prices_spike(query='Milch', fetcher=lambda _url: __import__('json').dumps(payload))
    candidate = result['candidates'][0]
    assert candidate['source_url'].endswith('/7')
    assert candidate['price_text'] is None
    assert candidate['can_count_as_secure_saving'] is False
    assert 'no_price' in candidate['quality_flags']

def test_open_prices_with_price_still_candidate_with_source_and_date() -> None:
    payload = {'items': [{'id': 8, 'price': '1.25', 'currency': 'CHF', 'date': '2026-05-19', 'product': {'code': '123', 'product_name': 'Milch', 'product_quantity': '1L'}, 'location': {'osm_name': 'Open Shop'}, 'proof': {'date': '2026-05-18'}}]}
    result = search_open_prices_spike(barcode='123', fetcher=lambda _url: __import__('json').dumps(payload))
    candidate = result['candidates'][0]
    assert candidate['price_text'] == '1.25'
    assert candidate['price_date'] == '2026-05-19'
    assert candidate['source_url'].endswith('/8')
    assert candidate['can_count_as_secure_saving'] is False
    assert candidate['attribution']

def test_manual_price_preview_confirm_audit_and_secure_saving_when_complete() -> None:
    conn = db()
    receipt = seed_receipt(conn)
    item_id = receipt['items'][0]['product_item_id']
    preview = preview_manual_grocery_mapping(conn, product_item_id=item_id, target_retailer='Denner', target_product_name='Denner Milch 1L', target_product_url='https://www.denner.ch/milch', candidate_price_text='1.10', candidate_unit_price_text='1.10/l', target_package_size='1L', target_unit='l', target_price_date='2026-05-19', target_price_source='manual/rappn_user_checked', user_note='manuell geprüft')
    assert preview['requires_confirm'] is True
    assert preview['can_count_as_secure_saving'] is True
    mapping = confirm_manual_grocery_mapping(conn, preview)
    assert mapping['target_price_date'] == '2026-05-19'
    assert mapping['target_price_source'] == 'manual/rappn_user_checked'
    audits = conn.execute("SELECT action FROM grocery_mapping_audit_events WHERE product_item_id=? ORDER BY created_at", (item_id,)).fetchall()
    assert [row['action'] for row in audits] == ['manual_mapping_preview', 'mapping_inserted']
    run = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[item_id])
    assert run['summary']['replaceable_product_count'] == 1
    assert run['summary']['estimated_savings_text'] == '0.40'

def test_manual_price_without_date_source_is_review_only_no_secure_saving() -> None:
    conn = db()
    receipt = seed_receipt(conn)
    item_id = receipt['items'][0]['product_item_id']
    preview = preview_manual_grocery_mapping(conn, product_item_id=item_id, target_retailer='Denner', target_product_name='Denner Milch 1L', target_product_url='https://www.denner.ch/milch', candidate_price_text='1.10', candidate_unit_price_text='1.10/l')
    assert preview['can_count_as_secure_saving'] is False
    mapping = confirm_manual_grocery_mapping(conn, preview)
    assert 'needs_review' in mapping['quality_flags_json']
    run = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[item_id])
    assert run['summary']['replaceable_product_count'] == 0
    assert run['summary']['estimated_savings_text'] == '0.00'


No linter for .txt files


terminal

python - <<'PY'
from pathlib import Path
p=Path('tests/unit/test_grocery_matching_learning_v2.py')
p.write_text(p.read_text()+Path('/tmp/new_tests.txt').read_text())
PY


Backend-Tests für Strategie/Manual/Open-Prices sind ergänzt. Ich lasse die fokussierte Suite laufen und fixiere erwartbare Brüche.

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


Output:
..F........F.                                                            [100%]
=================================== FAILURES ===================================
____ test_manual_mapping_can_be_saved_and_used_after_sourced_cache_refresh _____

    def test_manual_mapping_can_be_saved_and_used_after_sourced_cache_refresh() -> None:
        conn = db()
        receipt = seed_receipt(conn)
        mapping = create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Denner', target_product_name='Denner Milch 1L', target_product_url='https://www.denner.ch/milch', target_brand='Denner', target_package_size='1L', target_unit='l', match_type='manual_match', candidate_price_text='1.10', candidate_unit_price_text='1.10/l', user_note='manuell notiert')
        assert mapping['status'] == 'accepted'
        assert mapping['match_type'] == 'manual_match'
        assert mapping['last_price_checked_at'] is None
        run_before = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[receipt['items'][0]['product_item_id']])
        assert run_before['summary']['replaceable_product_count'] == 0
        conn.execute("INSERT INTO grocery_product_details_cache(detail_id,retailer,product_url,product_name,price_text,unit_price_text,package_size,ingredients_text,nutrition_json,fetched_at,cache_status,source_hash) VALUES ('d1','Denner','https://www.denner.ch/milch','Denner Milch 1L','1.10','1.10/l','1L',NULL,'{}','2026-05-19T10:00:00+00:00','cached','hash')")
        refreshed = refresh_grocery_mapping_price(conn, mapping['mapping_id'])
        assert refreshed['last_price_checked_at'] == '2026-05-19T10:00:00+00:00'
        run_after = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[receipt['items'][0]['product_item_id']])
>       assert run_after['optimized_total_text'] == '1.10'
E       AssertionError: assert '1.50' == '1.10'
E
E         - 1.10
E         ?   ^
E         + 1.50
E         ?   ^

tests/unit/test_grocery_matching_learning_v2.py:98: AssertionError
___ test_manual_price_preview_confirm_audit_and_secure_saving_when_complete ____

    def test_manual_price_preview_confirm_audit_and_secure_saving_when_complete() -> None:
        conn = db()
        receipt = seed_receipt(conn)
        item_id = receipt['items'][0]['product_item_id']
        preview = preview_manual_grocery_mapping(conn, product_item_id=item_id, target_retailer='Denner', target_product_name='Denner Milch 1L', target_product_url='https://www.denner.ch/milch', candidate_price_text='1.10', candidate_unit_price_text='1.10/l', target_package_size='1L', target_unit='l', target_price_date='2026-05-19', target_price_source='manual/rappn_user_checked', user_note='manuell geprüft')
        assert preview['requires_confirm'] is True
        assert preview['can_count_as_secure_saving'] is True
        mapping = confirm_manual_grocery_mapping(conn, preview)
        assert mapping['target_price_date'] == '2026-05-19'
        assert mapping['target_price_source'] == 'manual/rappn_user_checked'
        audits = conn.execute("SELECT action FROM grocery_mapping_audit_events WHERE product_item_id=? ORDER BY created_at", (item_id,)).fetchall()
        assert [row['action'] for row in audits] == ['manual_mapping_preview', 'mapping_inserted']
        run = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[item_id])
>       assert run['summary']['replaceable_product_count'] == 1
E       assert 0 == 1

tests/unit/test_grocery_matching_learning_v2.py:215: AssertionError
=========================== short test summary info ============================
FAILED tests/unit/test_grocery_matching_learning_v2.py::test_manual_mapping_can_be_saved_and_used_after_sourced_cache_refresh
FAILED tests/unit/test_grocery_matching_learning_v2.py::test_manual_price_preview_confirm_audit_and_secure_saving_when_complete
2 failed, 11 passed in 0.83s


from hermes_tools import terminal
cmd="""source ~/jarvis_runtime/finance-system/venv/bin/activate && PYTHONPATH=src python - <<'PY'
import sqlite3,json
from jarvis_finance.storage.migrations import apply_migrations
from jarvis_finance.services.grocery_optimizer import *
conn=sqlite3.connect(':memory:'); conn.row_factory=sqlite3.Row; apply_migrations(conn)
r=add_demo_migros_receipt(conn,purchase_date='2026-05-19',store_name='Migros',receipt_id='r1',items=[{'raw_product_name':'Milch 1L','quantity_text':'1L','unit':'l','unit_price_text':'1.50/l','total_price_text':'1.50'}])
item=r['items'][0]['product_item_id']
p=preview_manual_grocery_mapping(conn,product_item_id=item,target_retailer='Denner',target_product_name='Denner Milch',target_product_url='https://www.denner.ch/milch',candidate_price_text='1.10',candidate_unit_price_text='1.10/l',target_price_date='2026-05-19',target_price_source='manual/rappn_user_checked')
m=confirm_manual_grocery_mapping(conn,p); print(m)
print(known_mappings_for_item(conn,r['items'][0], True))
run=run_grocery_optimization(conn,'r1',selected_retailers=['Denner'],included_product_item_ids=[item]); print(run['product_comparisons'], run['summary'])
PY"""
print(terminal(cmd, workdir='/home/agent/.hermes/repos/FinanceManager', timeout=120)['output'])


Output:
{'mapping_id': 'gmap_9ba85393b5b54072', 'source_product_normalized_name': 'milch', 'source_product_raw_name': 'Milch 1L', 'source_retailer': 'Migros', 'target_retailer': 'Denner', 'target_product_name': 'Denner Milch', 'target_product_url': 'https://www.denner.ch/milch', 'target_brand': None, 'target_package_size': None, 'target_unit': None, 'target_price_text': '1.10', 'target_unit_price_text': '1.10/l', 'price_currency': 'CHF', 'match_type': 'manual_match', 'status': 'accepted', 'confidence': '0.95', 'user_note': None, 'source_match_id': None, 'quality_flags_json': '["manual_match", "manual_price"]', 'health_analysis_status': 'prepared_not_run', 'health_flags_json': '{}', 'health_notes': 'Gesundheitsanalyse später', 'created_at': '2026-05-19T16:19:08.296723+00:00', 'updated_at': '2026-05-19T16:19:08.296723+00:00', 'last_price_checked_at': '2026-05-19', 'target_price_date': '2026-05-19', 'target_price_source': 'manual/rappn_user_checked'}
[{'mapping_id': 'gmap_9ba85393b5b54072', 'source_product_normalized_name': 'milch', 'source_product_raw_name': 'Milch 1L', 'source_retailer': 'Migros', 'target_retailer': 'Denner', 'target_product_name': 'Denner Milch', 'target_product_url': 'https://www.denner.ch/milch', 'target_brand': None, 'target_package_size': None, 'target_unit': None, 'target_price_text': '1.10', 'target_unit_price_text': '1.10/l', 'price_currency': 'CHF', 'match_type': 'manual_match', 'status': 'accepted', 'confidence': '0.95', 'user_note': None, 'source_match_id': None, 'quality_flags_json': '["manual_match", "manual_price"]', 'health_analysis_status': 'prepared_not_run', 'health_flags_json': '{}', 'health_notes': 'Gesundheitsanalyse später', 'created_at': '2026-05-19T16:19:08.296723+00:00', 'updated_at': '2026-05-19T16:19:08.296723+00:00', 'last_price_checked_at': None, 'target_price_date': '2026-05-19', 'target_price_source': 'manual/rappn_user_checked', 'cache_status': 'fehlt'}]
[{'product_item_id': 'gitem_c0bc5a04b0fe4617', 'migros_product': 'Milch 1L', 'migros_price_text': '1.50', 'best_alternative': None, 'retailer': None, 'alternative_price_text': None, 'unit_price_text': None, 'savings_text': '0.00', 'confidence': '0', 'status': 'not_found', 'quality_flags': [], 'source_url': None}] {'replaceable_product_count': 0, 'uncertain_product_count': 0, 'estimated_savings_text': '0.00', 'optimized_lists': [{'variant': 'max_3_stores', 'store_count': 0, 'stores': [], 'not_found_products': ['Milch 1L'], 'needs_review_products': [], 'rejected_products': [], 'store_limit_omitted_products': []}], 'product_comparisons': [{'product_item_id': 'gitem_c0bc5a04b0fe4617', 'migros_product': 'Milch 1L', 'migros_price_text': '1.50', 'best_alternative': None, 'retailer': None, 'alternative_price_text': None, 'unit_price_text': None, 'savings_text': '0.00', 'confidence': '0', 'status': 'not_found', 'quality_flags': [], 'source_url': None}]}


Cache-Status überschreibt bei manuellen Mappings aktuell `last_price_checked_at` mit `None`. Das ist der Fehler. Ich fixiere: manuelles Preisdatum bleibt erhalten, Cache-Info kommt separat dazu.

patch

        cache = _cache_status_for_url(conn, d["target_retailer"], d["target_product_url"])
        d["cache_status"] = "manuell" if d.get("target_price_source", "").startswith("manual") and d.get("target_price_text") else cache["cache_status"]
        d["cache_last_price_checked_at"] = cache.get("last_price_checked_at")
        d["cache_price_text"] = cache.get("price_text")
        d["cache_unit_price_text"] = cache.get("unit_price_text")
        if cache.get("last_price_checked_at") and not d.get("last_price_checked_at"):
            d["last_price_checked_at"] = cache.get("last_price_checked_at")
        mappings.append(d)

        d.update(_cache_status_for_url(conn, d["target_retailer"], d["target_product_url"]))
        mappings.append(d)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -196,7 +196,13 @@
     mappings = []
     for row in rows:
         d = row_to_dict(row)
-        d.update(_cache_status_for_url(conn, d["target_retailer"], d["target_product_url"]))
+        cache = _cache_status_for_url(conn, d["target_retailer"], d["target_product_url"])
+        d["cache_status"] = "manuell" if d.get("target_price_source", "").startswith("manual") and d.get("target_price_text") else cache["cache_status"]
+        d["cache_last_price_checked_at"] = cache.get("last_price_checked_at")
+        d["cache_price_text"] = cache.get("price_text")
+        d["cache_unit_price_text"] = cache.get("unit_price_text")
+        if cache.get("last_price_checked_at") and not d.get("last_price_checked_at"):
+            d["last_price_checked_at"] = cache.get("last_price_checked_at")
         mappings.append(d)
     return mappings


flags = [f for f in json.loads(mapping.get("quality_flags_json") or "[]") if f not in {"manual_price", "needs_review", "no_price", "no_unit_price"}]

flags = [f for f in json.loads(mapping.get("quality_flags_json") or "[]") if f != "manual_price"]


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -311,7 +311,7 @@
     mapping = row_to_dict(row)
     cache = _cache_status_for_url(conn, mapping["target_retailer"], mapping["target_product_url"])
     if cache.get("price_text"):
-        flags = [f for f in json.loads(mapping.get("quality_flags_json") or "[]") if f != "manual_price"]
+        flags = [f for f in json.loads(mapping.get("quality_flags_json") or "[]") if f not in {"manual_price", "needs_review", "no_price", "no_unit_price"}]
         if "manual_match" not in flags:
             flags.append("manual_match")
         conn.execute(


terminal

{"output": "F.F.FF.......                                                            [100%]\n=================================== FAILURES ===================================\n_ test_accept_match_creates_preferred_mapping_and_next_receipt_reuses_without_provider_call _\n\n    def test_accept_match_creates_preferred_mapping_and_next_receipt_reuses_without_provider_call() -> None:\n        conn = db()\n        first = seed_receipt(conn, receipt_id='r1')\n        provider = CoopProvider(fetcher=lambda _url: '
Prix Garantie Milch 1LCHF 1.201.20/l
')\n        found = search_and_store_product_matches(conn, 'r1', providers=[provider], included_product_item_ids=[first['items'][0]['product_item_id']], use_cache=False)\n        accepted = accept_grocery_product_match(conn, found['matches'][0]['match_id'], user_note='passt')\n        assert accepted['status'] == 'accepted'\n        assert accepted['target_retailer'] == 'Coop'\n    \n        second = seed_receipt(conn, receipt_id='r2')\n        fail_provider = CoopProvider(fetcher=lambda _url: (_ for _ in ()).throw(AssertionError('provider must not be called')))\n>       reused = search_and_store_product_matches(conn, 'r2', providers=[fail_provider], included_product_item_ids=[second['items'][0]['product_item_id']], prefer_known_mappings=True, use_cache=True)\n                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\ntests/unit/test_grocery_matching_learning_v2.py:61: \n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \nsrc/jarvis_finance/services/grocery_price_providers.py:350: in search_and_store_product_matches\n    accepted = [m for m in known_mappings_for_item(conn, item, include_rejected=False) if m.get(\"status\") == \"accepted\"]\n                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \n\nconn = <sqlite3.Connection object at 0x766ebda113f0>\nitem = {'action_label': None, 'brand_hint': '', 'created_at': '2026-05-19T16:19:38.537226+00:00', 'currency': 'CHF', ...}\ninclude_rejected = False\n\n    def known_mappings_for_item(conn: Connection, item: dict[str, Any], include_rejected: bool = False) -> list[dict[str, Any]]:\n        statuses = (\"accepted\", \"needs_review\", \"rejected\") if include_rejected else (\"accepted\", \"needs_review\")\n        rows = conn.execute(\n            f\"SELECT * FROM grocery_product_mappings WHERE source_retailer='Migros' AND source_product_normalized_name=? AND status IN ({','.join('?' for _ in statuses)}) ORDER BY status='accepted' DESC, CAST(confidence AS REAL) DESC, updated_at DESC\",\n            (item.get(\"normalized_product_name\"), *statuses),\n        ).fetchall()\n        mappings = []\n        for row in rows:\n            d = row_to_dict(row)\n            cache = _cache_status_for_url(conn, d[\"target_retailer\"], d[\"target_product_url\"])\n>           d[\"cache_status\"] = \"manuell\" if d.get(\"target_price_source\", \"\").startswith(\"manual\") and d.get(\"target_price_text\") else cache[\"cache_status\"]\n                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nE           AttributeError: 'NoneType' object has no attribute 'startswith'\n\nsrc/jarvis_finance/services/grocery_optimizer.py:200: AttributeError\n____ test_manual_mapping_can_be_saved_and_used_after_sourced_cache_refresh _____\n\n    def test_manual_mapping_can_be_saved_and_used_after_sourced_cache_refresh() -> None:\n        conn = db()\n        receipt = seed_receipt(conn)\n        mapping = create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Denner', target_product_name='Denner Milch 1L', target_product_url='https://www.denner.ch/milch', target_brand='Denner', target_package_size='1L', target_unit='l', match_type='manual_match', candidate_price_text='1.10', candidate_unit_price_text='1.10/l', user_note='manuell notiert')\n        assert mapping['status'] == 'accepted'\n        assert mapping['match_type'] == 'manual_match'\n        assert mapping['last_price_checked_at'] is None\n>       run_before = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[receipt['items'][0]['product_item_id']])\n                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\ntests/unit/test_grocery_matching_learning_v2.py:92: \n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \nsrc/jarvis_finance/services/grocery_optimizer.py:357: in run_grocery_optimization\n    for mapping in known_mappings_for_item(conn, item, include_rejected=False):\n                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \n\nconn = <sqlite3.Connection object at 0x766ebda122f0>\nitem = {'action_label': None, 'brand_hint': '', 'created_at': '2026-05-19T16:19:38.682879+00:00', 'currency': 'CHF', ...}\ninclude_rejected = False\n\n    def known_mappings_for_item(conn: Connection, item: dict[str, Any], include_rejected: bool = False) -> list[dict[str, Any]]:\n        statuses = (\"accepted\", \"needs_review\", \"rejected\") if include_rejected else (\"accepted\", \"needs_review\")\n        rows = conn.execute(\n            f\"SELECT * FROM grocery_product_mappings WHERE source_retailer='Migros' AND source_product_normalized_name=? AND status IN ({','.join('?' for _ in statuses)}) ORDER BY status='accepted' DESC, CAST(confidence AS REAL) DESC, updated_at DESC\",\n            (item.get(\"normalized_product_name\"), *statuses),\n        ).fetchall()\n        mappings = []\n        for row in rows:\n            d = row_to_dict(row)\n            cache = _cache_status_for_url(conn, d[\"target_retailer\"], d[\"target_product_url\"])\n>           d[\"cache_status\"] = \"manuell\" if d.get(\"target_price_source\", \"\").startswith(\"manual\") and d.get(\"target_price_text\") else cache[\"cache_status\"]\n                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nE           AttributeError: 'NoneType' object has no attribute 'startswith'\n\nsrc/jarvis_finance/services/grocery_optimizer.py:200: AttributeError\n___________ test_dashboard_surfaces_known_mappings_and_cache_status ____________\n\n    def test_dashboard_surfaces_known_mappings_and_cache_status() -> None:\n        conn = db()\n        receipt = seed_receipt(conn)\n        mapping = create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Coop Milch 1L', target_product_url='https://coop.example/milch', match_type='manual_match', candidate_price_text='1.20')\n>       dash = get_grocery_optimizer_dashboard(conn)\n               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\ntests/unit/test_grocery_matching_learning_v2.py:115: \n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \nsrc/jarvis_finance/services/grocery_optimizer.py:121: in get_grocery_optimizer_dashboard\n    item[\"known_mappings\"] = known_mappings_for_item(conn, item, include_rejected=True)\n                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \n\nconn = <sqlite3.Connection object at 0x766ebda12d40>\nitem = {'action_label': None, 'brand_hint': '', 'created_at': '2026-05-19T16:19:38.808030+00:00', 'currency': 'CHF', ...}\ninclude_rejected = True\n\n    def known_mappings_for_item(conn: Connection, item: dict[str, Any], include_rejected: bool = False) -> list[dict[str, Any]]:\n        statuses = (\"accepted\", \"needs_review\", \"rejected\") if include_rejected else (\"accepted\", \"needs_review\")\n        rows = conn.execute(\n            f\"SELECT * FROM grocery_product_mappings WHERE source_retailer='Migros' AND source_product_normalized_name=? AND status IN ({','.join('?' for _ in statuses)}) ORDER BY status='accepted' DESC, CAST(confidence AS REAL) DESC, updated_at DESC\",\n            (item.get(\"normalized_product_name\"), *statuses),\n        ).fetchall()\n        mappings = []\n        for row in rows:\n            d = row_to_dict(row)\n            cache = _cache_status_for_url(conn, d[\"target_retailer\"], d[\"target_product_url\"])\n>           d[\"cache_status\"] = \"manuell\" if d.get(\"target_price_source\", \"\").startswith(\"manual\") and d.get(\"target_price_text\") else cache[\"cache_status\"]\n                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nE           AttributeError: 'NoneType' object has no attribute 'startswith'\n\nsrc/jarvis_finance/services/grocery_optimizer.py:200: AttributeError\n___ test_report_contains_known_mappings_sources_and_disclaimer_runtime_only ____\n\ntmp_path = PosixPath('/tmp/pytest-of-agent/pytest-328/test_report_contains_known_map0')\n\n    def test_report_contains_known_mappings_sources_and_disclaimer_runtime_only(tmp_path: Path) -> None:\n        conn = db()\n        receipt = seed_receipt(conn)\n        create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Coop Milch 1L', target_product_url='https://coop.example/milch', match_type='manual_match', candidate_price_text='1.20')\n>       run = run_grocery_optimization(conn, 'r1', selected_retailers=['Coop'], included_product_item_ids=[receipt['items'][0]['product_item_id']])\n              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\ntests/unit/test_grocery_matching_learning_v2.py:125: \n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \nsrc/jarvis_finance/services/grocery_optimizer.py:357: in run_grocery_optimization\n    for mapping in known_mappings_for_item(conn, item, include_rejected=False):\n                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \n\nconn = <sqlite3.Connection object at 0x766ebda13010>\nitem = {'action_label': None, 'brand_hint': '', 'created_at': '2026-05-19T16:19:38.879890+00:00', 'currency': 'CHF', ...}\ninclude_rejected = False\n\n    def known_mappings_for_item(conn: Connection, item: dict[str, Any], include_rejected: bool = False) -> list[dict[str, Any]]:\n        statuses = (\"accepted\", \"needs_review\", \"rejected\") if include_rejected else (\"accepted\", \"needs_review\")\n        rows = conn.execute(\n            f\"SELECT * FROM grocery_product_mappings WHERE source_retailer='Migros' AND source_product_normalized_name=? AND status IN ({','.join('?' for _ in statuses)}) ORDER BY status='accepted' DESC, CAST(confidence AS REAL) DESC, updated_at DESC\",\n            (item.get(\"normalized_product_name\"), *statuses),\n        ).fetchall()\n        mappings = []\n        for row in rows:\n            d = row_to_dict(row)\n            cache = _cache_status_for_url(conn, d[\"target_retailer\"], d[\"target_product_url\"])\n>           d[\"cache_status\"] = \"manuell\" if d.get(\"target_price_source\", \"\").startswith(\"manual\") and d.get(\"target_price_text\") else cache[\"cache_status\"]\n                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nE           AttributeError: 'NoneType' object has no attribute 'startswith'\n\nsrc/jarvis_finance/services/grocery_optimizer.py:200: AttributeError\n=========================== short test summary info ============================\nFAILED tests/unit/test_grocery_matching_learning_v2.py::test_accept_match_creates_preferred_mapping_and_next_receipt_reuses_without_provider_call\nFAILED tests/unit/test_grocery_matching_learning_v2.py::test_manual_mapping_can_be_saved_and_used_after_sourced_cache_refresh\nFAILED tests/unit/test_grocery_matching_learning_v2.py::test_dashboard_surfaces_known_mappings_and_cache_status\nFAILED tests/unit/test_grocery_matching_learning_v2.py::test_report_contains_known_mappings_sources_and_disclaimer_runtime_only\n4 failed, 9 passed in 0.84s", "exit_code": 1, "error": null}

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


Null-Handling im Cache-Status ist der aktuelle Bruch; ich korrigiere `target_price_source` robust.

patch

        price_source = str(d.get("target_price_source") or "")
        d["cache_status"] = "manuell" if price_source.startswith("manual") and d.get("target_price_text") else cache["cache_status"]

d["cache_status"] = "manuell" if d.get("target_price_source", "").startswith("manual") and d.get("target_price_text") else cache["cache_status"]


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -197,7 +197,8 @@
     for row in rows:
         d = row_to_dict(row)
         cache = _cache_status_for_url(conn, d["target_retailer"], d["target_product_url"])
-        d["cache_status"] = "manuell" if d.get("target_price_source", "").startswith("manual") and d.get("target_price_text") else cache["cache_status"]
+        price_source = str(d.get("target_price_source") or "")
+        d["cache_status"] = "manuell" if price_source.startswith("manual") and d.get("target_price_text") else cache["cache_status"]
         d["cache_last_price_checked_at"] = cache.get("last_price_checked_at")
         d["cache_price_text"] = cache.get("price_text")
         d["cache_unit_price_text"] = cache.get("unit_price_text")


terminal

Output:
.............                                                            [100%]
13 passed in 0.80s


Backend-fokussierte Tests sind grün. Ich binde Preview/Confirm/Strategy jetzt im Frontend-API/UI an und ergänze UI-Tests.

Explanation:
**Updating API plans**

I need to update the frontend API. First, I’ll check the existing API budget to see what's available. It’s essential to ensure I’m working within our financial limits while considering the necessary changes. Okay, let’s dive into the specifics of the current budget and how it can support the update process. I’ll take my time to evaluate the requirements as well as any potential implications the update may have.


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


patch

export type GroceryMapping = { mapping_id: string; target_retailer: string; target_product_name: string; target_product_url: string; status: string; match_type?: string; confidence?: string; cache_status?: string; last_price_checked_at?: string; target_price_text?: string; target_unit_price_text?: string; target_price_date?: string; target_price_source?: string }
export type GroceryManualMappingPreview = { preview_id: string; product_item_id: string; target_retailer: string; target_product_name: string; target_product_url: string; target_package_size?: string; target_unit?: string; candidate_price_text?: string; candidate_unit_price_text?: string; target_price_date?: string; target_price_source?: string; quality_flags: string[]; can_count_as_secure_saving: boolean; requires_confirm: boolean }

export type GroceryMapping = { mapping_id: string; target_retailer: string; target_product_name: string; target_product_url: string; status: string; match_type?: string; confidence?: string; cache_status?: string; last_price_checked_at?: string; target_price_text?: string; target_unit_price_text?: string }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -16,7 +16,8 @@
 export type BudgetMonthlyImportDashboard = { purpose: 'budget_monthly_import_dashboard_v1'; latest_import_by_source: Record<string, BudgetImportSession>; open_candidates_by_source: Record<string, number>; counts: Record<string, number>; rule_suggestions_open: number; review_url: string }
 export type BudgetMonthlyImportResult = { purpose: string; status: string; files_total: number; session_ids: string[]; files: Array<Record<string, unknown>>; totals: Record<string, number>; review_url: string }
 export type BudgetRuleSuggestion = { suggestion_id: string; merchant_pattern: string; match_type: string; source_scope: string; category_id?: string | null; category_name?: string | null; recurring_type?: string | null; confidence: string; affected_open_candidate_count: number; status: string; created_at: string }
-export type GroceryMapping = { mapping_id: string; target_retailer: string; target_product_name: string; target_product_url: string; status: string; match_type?: string; confidence?: string; cache_status?: string; last_price_checked_at?: string; target_price_text?: string; target_unit_price_text?: string }
+export type GroceryMapping = { mapping_id: string; target_retailer: string; target_product_name: string; target_product_url: string; status: string; match_type?: string; confidence?: string; cache_status?: string; last_price_checked_at?: string; target_price_text?: string; target_unit_price_text?: string; target_price_date?: string; target_price_source?: string }
+export type GroceryManualMappingPreview = { preview_id: string; product_item_id: string; target_retailer: string; target_product_name: string; target_product_url: string; target_package_size?: string; target_unit?: string; candidate_price_text?: string; candidate_unit_price_text?: string; target_price_date?: string; target_price_source?: string; quality_flags: string[]; can_count_as_secure_saving: boolean; requires_confirm: boolean }
 export type GroceryProductItem = { product_item_id: string; receipt_id: string; purchase_date: string; store_name: string; raw_product_name: string; normalized_product_name: string; quantity_text?: string; unit?: string; unit_price_text?: string; total_price_text: string; action_label?: string; include_in_analysis: number; health_analysis_status: string; known_mappings?: GroceryMapping[] }
 export type GroceryReceipt = { receipt_id: string; purchase_date: string; store_name: string; article_count: number; total_text: string; items: GroceryProductItem[] }
 export type GroceryDashboard = { purpose: 'migros_product_savings_grocery_optimizer_v1'; receipts: GroceryReceipt[]; supported_retailers: string[]; health_analysis_status: string; disclaimer: string }

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