     1|from __future__ import annotations
     2|
     3|from fastapi import APIRouter, Depends
     4|from sqlite3 import Connection
     5|
     6|from jarvis_finance.api.dependencies import get_db
     7|from jarvis_finance.services.budget_accounts import archive_budget_account, confirm_create_budget_account, create_budget_account_preview, list_budget_accounts
     8|from jarvis_finance.services.budget_categories import (
     9|    archive_category,
    10|    archive_tag,
    11|    confirm_budget_category_cleanup,
    12|    confirm_category_merge,
    13|    confirm_create_category,
    14|    confirm_create_tag,
    15|    create_category_preview,
    16|    create_tag_preview,
    17|    delete_empty_category,
    18|    list_category_tree_grouped,
    19|    list_categories,
    20|    list_tags,
    21|    preview_budget_category_cleanup,
    22|    preview_category_merges,
    23|    reorder_categories,
    24|    update_category,
    25|)
    26|from jarvis_finance.services.budget_csv_imports import import_budget_csv_text, import_profile_specs
    27|from jarvis_finance.services.budget_imports import (
    28|    archive_reference_year_candidates,
    29|    apply_budget_rules_to_open_candidates,
    30|    apply_merchant_to_open_candidates,
    31|    batch_confirm_safe_candidates,
    32|    batch_mark_candidates,
    33|    confirm_migros_receipts_2026,
    34|    bulk_update_transaction_candidate_category,
    35|    confirm_candidate_split,
    36|    confirm_transaction_candidate,
    37|    create_budget_rule,
    38|    create_merchant,
    39|    create_merchant_alias,
    40|    create_candidate_split,
    41|    create_review_category_for_candidate,
    42|    deactivate_budget_rule,
    43|    detect_duplicate_candidates,
    44|    ignore_transaction_candidate,
    45|    list_budget_rules,
    46|    list_merchants,
    47|    list_transaction_candidates,
    48|    merge_merchants,
    49|    mark_candidate_transfer,
    50|    mark_candidate_type,
    51|    preview_migros_receipts_2026,
    52|    preview_batch_confirm_candidates,
    53|    preview_safe_auto_candidates,
    54|    preview_transaction_candidate,
    55|    prioritize_budget_rule,
    56|    reclassify_bank_income_candidates,
    57|    reopen_transaction_candidate,
    58|    review_candidate_summary,
    59|    seed_bank_transfer_candidates_from_rows,
    60|    seed_credit_card_candidates_from_rows,
    61|    seed_migros_candidates_from_rows,
    62|    seed_transaction_candidates_from_csv_text,
    63|    seed_transaction_candidates_from_rows,
    64|    test_budget_rule,
    65|    update_budget_rule,
    66|    update_merchant_category,
    67|    update_transaction_candidate_category,
    68|)
    69|from jarvis_finance.services.budget_analytics import get_monthly_comparison
    70|from jarvis_finance.services.budget_analytics_v2 import get_budget_dashboard_cockpit, get_budget_vs_actual_analysis
    71|from jarvis_finance.services.budget_data_explorer import get_budget_data_explorer, get_budget_deviation_analysis, get_category_analysis_v2, get_merchant_analysis
    72|from jarvis_finance.services.budget_overview import get_budget_charts_2026, get_budget_overview, get_budget_status_by_category, get_category_month_detail, get_category_summary, get_income_status, get_monthly_summary
    73|from jarvis_finance.services.budget_planning import (
    74|    confirm_budget_planning_batch,
    75|    confirm_category_budget_values,
    76|    get_budget_planning_audit_timeline,
    77|    get_budget_planning_matrix,
    78|    preview_budget_planning_batch,
    79|    preview_budget_planning_excel_template,
    80|    preview_category_budget_values,
    81|)
    82|from jarvis_finance.services.budget_recurring import (
    83|    archive_recurring_payment,
    84|    confirm_manual_recurring_payment,
    85|    confirm_recurring_candidate_action,
    86|    detect_recurring_payment_candidates,
    87|    get_recurring_dashboard,
    88|    preview_manual_recurring_payment,
    89|    preview_recurring_candidate_action,
    90|)
    91|from jarvis_finance.services.budget_monthly_import import (
    92|    apply_rule_suggestion,
    93|    get_import_history,
    94|    get_monthly_import_dashboard,
    95|    list_rule_suggestions,
    96|    preview_drive_monthly_import,
    97|    reject_rule_suggestion,
    98|    run_monthly_import_dry_run,
    99|    scan_budget_drive_files,
   100|    scan_google_drive_budget_folder,
   101|)
   102|from jarvis_finance.services.grocery_optimizer import (
   103|    accept_grocery_product_match,
   104|    add_demo_migros_receipt,
   105|    build_rappn_link_out,
   106|    confirm_manual_grocery_mapping,
   107|    create_manual_grocery_mapping,
   108|    generate_grocery_optimization_report,
   109|    get_grocery_optimizer_dashboard,
   110|    get_grocery_provider_strategy,
   111|    preview_manual_grocery_mapping,
   112|    refresh_grocery_mapping_price,
   113|    reject_grocery_product_match,
   114|    run_grocery_optimization,
   115|    upsert_product_match,
   116|)
   117|from jarvis_finance.services.grocery_price_providers import provider_for_retailer, search_and_store_product_matches, search_open_prices_spike
   118|from jarvis_finance.services.budget_plans import (
   119|    archive_budget_plan_item,
   120|    confirm_budget_plan_item,
   121|    confirm_income_plan_templates,
   122|    confirm_update_income_plan_item,
   123|    create_budget_plan_item_preview,
   124|    list_budget_plan_items,
   125|    list_income_plan_items,
   126|    list_income_seed_candidates,
   127|    preview_income_plan_templates,
   128|    preview_update_income_plan_item,
   129|    reorder_income_plan_items,
   130|)
   131|from jarvis_finance.services.budget_seed_review import (
   132|    confirm_budget_plan_takeover,
   133|    confirm_clear_category_takeover,
   134|    confirm_seed_candidate,
   135|    convert_unclean_range,
   136|    ignore_seed_candidate,
   137|    list_seed_candidates,
   138|    preview_budget_plan_takeover,
   139|    preview_clear_category_takeover,
   140|    preview_seed_candidate,
   141|    reopen_seed_candidate,
   142|    seed_budget_candidates_from_rows,
   143|    set_seed_candidate_status,
   144|)
   145|from jarvis_finance.services.budget_transactions import (
   146|    confirm_budget_transaction,
   147|    confirm_reverse_budget_transaction,
   148|    confirm_transfer,
   149|    create_budget_transaction_preview,
   150|    create_transfer_preview,
   151|    list_budget_transactions,
   152|    list_budget_transfers,
   153|    reverse_budget_transaction_preview,
   154|    update_budget_transaction,
   155|)
   156|
   157|router = APIRouter(prefix="/budget", tags=["budget"])
   158|
   159|
   160|@router.get("/grocery-optimizer/dashboard")
   161|def grocery_optimizer_dashboard(conn: Connection = Depends(get_db)):
   162|    return get_grocery_optimizer_dashboard(conn)
   163|
   164|
   165|@router.get("/grocery-optimizer/provider-strategy")
   166|def grocery_optimizer_provider_strategy():
   167|    return get_grocery_provider_strategy()
   168|
   169|
   170|@router.get("/grocery-optimizer/rappn-link")
   171|def grocery_optimizer_rappn_link(product_name: str):
   172|    return build_rappn_link_out(product_name)
   173|
   174|
   175|@router.post("/grocery-optimizer/open-prices-spike")
   176|def grocery_optimizer_open_prices_spike(payload: dict):
   177|    return search_open_prices_spike(query=payload.get("query"), barcode=payload.get("barcode"), size=int(payload.get("size") or 5))
   178|
   179|
   180|@router.post("/grocery-optimizer/demo-receipt")
   181|def grocery_optimizer_demo_receipt(payload: dict, conn: Connection = Depends(get_db)):
   182|    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 [])
   183|
   184|
   185|@router.post("/grocery-optimizer/matches")
   186|def grocery_optimizer_match(payload: dict, conn: Connection = Depends(get_db)):
   187|    return upsert_product_match(conn, payload["product_item_id"], retailer=payload["retailer"], candidate_product_name=payload["candidate_product_name"], candidate_url=payload.get("candidate_url") or "https://example.invalid/review", candidate_price_text=payload["candidate_price_text"], candidate_unit_price_text=payload.get("candidate_unit_price_text"), quality_flags=payload.get("quality_flags") or ["needs_review"], match_confidence=payload.get("match_confidence") or "0.60", status=payload.get("status") or "suggested", source=payload.get("source") or "manual")
   188|
   189|
   190|@router.post("/grocery-optimizer/matches/{match_id}/accept")
   191|def grocery_optimizer_accept_match(match_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   192|    payload = payload or {}
   193|    return accept_grocery_product_match(conn, match_id, user_note=payload.get("user_note"), save_as_preferred=bool(payload.get("save_as_preferred", True)))
   194|
   195|
   196|@router.post("/grocery-optimizer/matches/{match_id}/reject")
   197|def grocery_optimizer_reject_match(match_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   198|    return reject_grocery_product_match(conn, match_id, user_note=(payload or {}).get("user_note"))
   199|
   200|
   201|@router.post("/grocery-optimizer/manual-mapping/preview")
   202|def grocery_optimizer_manual_mapping_preview(payload: dict, conn: Connection = Depends(get_db)):
   203|    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"))
   204|
   205|
   206|@router.post("/grocery-optimizer/manual-mapping/confirm")
   207|def grocery_optimizer_manual_mapping_confirm(payload: dict, conn: Connection = Depends(get_db)):
   208|    return confirm_manual_grocery_mapping(conn, payload)
   209|
   210|
   211|@router.post("/grocery-optimizer/manual-mapping")
   212|def grocery_optimizer_manual_mapping(payload: dict, conn: Connection = Depends(get_db)):
   213|    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"))
   214|
   215|
   216|@router.post("/grocery-optimizer/mappings/{mapping_id}/refresh-price")
   217|def grocery_optimizer_refresh_mapping_price(mapping_id: str, conn: Connection = Depends(get_db)):
   218|    return refresh_grocery_mapping_price(conn, mapping_id)
   219|
   220|
   221|@router.post("/grocery-optimizer/optimize")
   222|def grocery_optimizer_run(payload: dict, conn: Connection = Depends(get_db)):
   223|    return run_grocery_optimization(conn, payload["receipt_id"], selected_retailers=payload.get("selected_retailers") or ["Coop", "Aldi Suisse", "Lidl", "Denner", "Otto's"], max_store_count=int(payload.get("max_store_count") or 3), included_product_item_ids=payload.get("included_product_item_ids"))
   224|
   225|
   226|@router.post("/grocery-optimizer/search-prices")
   227|def grocery_optimizer_search_prices(payload: dict, conn: Connection = Depends(get_db)):
   228|    retailers = (payload.get("retailers") or ["Coop"])[: int(payload.get("max_retailers") or 3)]
   229|    providers = [provider_for_retailer(retailer) for retailer in retailers]
   230|    return search_and_store_product_matches(
   231|        conn,
   232|        payload["receipt_id"],
   233|        providers=providers,
   234|        included_product_item_ids=payload.get("included_product_item_ids"),
   235|        max_products=int(payload.get("max_products") or 5),
   236|        use_cache=bool(payload.get("use_cache", True)),
   237|        only_safe_matches=bool(payload.get("only_safe_matches", True)),
   238|        prefer_known_mappings=bool(payload.get("prefer_known_mappings", True)),
   239|    )
   240|
   241|

Use offset=241 to continue reading (showing 1-240 of 932 lines)


   500|@router.get("/accounts")
   501|def accounts(include_archived: bool = False, conn: Connection = Depends(get_db)):
   502|    return list_budget_accounts(conn, include_archived=include_archived)
   503|
   504|
   505|@router.post("/accounts/preview")
   506|def accounts_preview(payload: dict, conn: Connection = Depends(get_db)):
   507|    return create_budget_account_preview(conn, payload)
   508|
   509|
   510|@router.post("/accounts/confirm")
   511|def accounts_confirm(payload: dict, conn: Connection = Depends(get_db)):
   512|    return confirm_create_budget_account(conn, payload)
   513|
   514|
   515|@router.post("/accounts/{account_id}/archive")
   516|def accounts_archive(account_id: str, conn: Connection = Depends(get_db)):
   517|    return archive_budget_account(conn, account_id)
   518|
   519|
   520|@router.get("/categories")
   521|def categories(include_archived: bool = False, grouped: bool = False, conn: Connection = Depends(get_db)):
   522|    if grouped:
   523|        return list_category_tree_grouped(conn, include_archived=include_archived)
   524|    return list_categories(conn, include_archived=include_archived)
   525|
   526|
   527|@router.get("/categories/tree")
   528|def category_tree(include_archived: bool = False, conn: Connection = Depends(get_db)):
   529|    return list_category_tree_grouped(conn, include_archived=include_archived)
   530|
   531|
   532|@router.get("/tags")
   533|def tags(include_archived: bool = False, conn: Connection = Depends(get_db)):
   534|    return list_tags(conn, include_archived=include_archived)
   535|
   536|
   537|@router.post("/tags/preview")
   538|def tags_preview(payload: dict, conn: Connection = Depends(get_db)):
   539|    return create_tag_preview(conn, payload)
   540|
   541|
   542|@router.post("/tags/confirm")
   543|def tags_confirm(payload: dict, conn: Connection = Depends(get_db)):
   544|    return confirm_create_tag(conn, payload)
   545|
   546|
   547|@router.post("/tags/{tag_id}/archive")
   548|def tags_archive(tag_id: str, conn: Connection = Depends(get_db)):
   549|    return archive_tag(conn, tag_id)
   550|
   551|
   552|@router.get("/plans")
   553|def plans(month: str | None = None, include_archived: bool = True, conn: Connection = Depends(get_db)):
   554|    return list_budget_plan_items(conn, month=month, include_archived=include_archived)
   555|
   556|
   557|@router.post("/plans/preview")
   558|def plans_preview(payload: dict, conn: Connection = Depends(get_db)):
   559|    return create_budget_plan_item_preview(conn, payload)
   560|
   561|
   562|@router.post("/plans/confirm")
   563|def plans_confirm(payload: dict, conn: Connection = Depends(get_db)):
   564|    return confirm_budget_plan_item(conn, payload)
   565|
   566|
   567|@router.get("/seed-candidates")
   568|def seed_candidates(status: str | None = None, candidate_type: str | None = None, conn: Connection = Depends(get_db)):
   569|    return list_seed_candidates(conn, status=status, candidate_type=candidate_type)
   570|
   571|
   572|@router.get("/import-profiles")
   573|def budget_import_profiles():
   574|    return import_profile_specs()
   575|
   576|
   577|@router.post("/import-profiles/{profile_name}/dry-run")
   578|def budget_import_profile_dry_run(profile_name: str, payload: dict, conn: Connection = Depends(get_db)):
   579|    return import_budget_csv_text(conn, profile_name, str(payload.get("csv_text") or ""), source_file_label=payload.get("source_file_label") or f"{profile_name}.csv", dry_run=True, default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   580|
   581|
   582|@router.post("/import-profiles/{profile_name}/candidates")
   583|def budget_import_profile_candidates(profile_name: str, payload: dict, conn: Connection = Depends(get_db)):
   584|    return import_budget_csv_text(conn, profile_name, str(payload.get("csv_text") or ""), source_file_label=payload.get("source_file_label") or f"{profile_name}.csv", dry_run=False, default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   585|
   586|
   587|@router.get("/transaction-candidates")
   588|def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
   589|    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)
   590|
   591|
   592|@router.get("/transaction-candidates/summary")
   593|def transaction_candidates_summary(budget_year: str = "2026", conn: Connection = Depends(get_db)):
   594|    return review_candidate_summary(conn, budget_year=budget_year)
   595|
   596|
   597|@router.post("/transaction-candidates/generate")
   598|def transaction_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   599|    source_type = payload.get("source_type") or "csv_seed"
   600|    rows = payload.get("rows") or []
   601|    if source_type == "credit_card_csv":
   602|        return seed_credit_card_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Credit card CSV", subscription_category_id=payload.get("subscription_category_id"), migros_covered=payload.get("migros_covered", True))
   603|    if source_type == "migros_csv":
   604|        return seed_migros_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Migros CSV", auto_category_id=payload.get("auto_category_id"), threshold_chf=str(payload.get("threshold_chf") or "50.00"))
   605|    if source_type == "bank_csv":
   606|        return seed_bank_transfer_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Bank CSV", income_category_id=payload.get("income_category_id"))
   607|    if payload.get("csv_text"):
   608|        return seed_transaction_candidates_from_csv_text(conn, payload["csv_text"], source_file_label=payload.get("source_file_label") or "CSV Import", source_type=source_type)
   609|    return seed_transaction_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Budget Import", source_type=source_type)
   610|
   611|
   612|@router.post("/transaction-candidates/bank-income/reclassify")
   613|def transaction_candidates_bank_income_reclassify(payload: dict | None = None, conn: Connection = Depends(get_db)):
   614|    return reclassify_bank_income_candidates(conn, budget_year=str((payload or {}).get("budget_year") or "2026"))
   615|
   616|
   617|@router.post("/transaction-candidates/migros-2026/preview")
   618|def transaction_candidates_migros_2026_preview(payload: dict | None = None, conn: Connection = Depends(get_db)):
   619|    return preview_migros_receipts_2026(conn, year=str((payload or {}).get("year") or "2026"))
   620|
   621|
   622|@router.post("/transaction-candidates/migros-2026/confirm")
   623|def transaction_candidates_migros_2026_confirm(payload: dict, conn: Connection = Depends(get_db)):
   624|    return confirm_migros_receipts_2026(conn, account_id=str(payload.get("account_id") or ""), year=str(payload.get("year") or "2026"))
   625|
   626|
   627|@router.post("/transaction-candidates/{candidate_id}/preview")
   628|def transaction_candidates_preview(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   629|    return preview_transaction_candidate(conn, candidate_id, payload or {})
   630|
   631|
   632|@router.post("/transaction-candidates/{candidate_id}/confirm")
   633|def transaction_candidates_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   634|    return confirm_transaction_candidate(conn, candidate_id, payload)
   635|
   636|
   637|@router.post("/transaction-candidates/{candidate_id}/ignore")
   638|def transaction_candidates_ignore(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   639|    return ignore_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   640|
   641|
   642|@router.post("/transaction-candidates/{candidate_id}/reopen")
   643|def transaction_candidates_reopen(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   644|    return reopen_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   645|
   646|
   647|@router.post("/transaction-candidates/{candidate_id}/category")
   648|def transaction_candidates_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   649|    return update_transaction_candidate_category(conn, candidate_id, payload.get("category_id"), payload.get("note"))
   650|
   651|
   652|@router.post("/transaction-candidates/bulk-category")
   653|def transaction_candidates_bulk_category(payload: dict, conn: Connection = Depends(get_db)):
   654|    category_id = str(payload.get("category_id") or "")
   655|    return bulk_update_transaction_candidate_category(conn, candidate_ids=payload.get("candidate_ids") or [], category_id=category_id, note=payload.get("note"))
   656|
   657|
   658|@router.post("/transaction-candidates/{candidate_id}/split")
   659|def transaction_candidates_split(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   660|    return create_candidate_split(conn, candidate_id, payload.get("splits") or [])
   661|
   662|
   663|@router.post("/transaction-candidates/{candidate_id}/split/confirm")
   664|def transaction_candidates_split_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   665|    return confirm_candidate_split(conn, candidate_id, payload)
   666|
   667|
   668|@router.post("/transaction-candidates/{candidate_id}/category/create")
   669|def transaction_candidates_create_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   670|    return create_review_category_for_candidate(conn, candidate_id, payload)
   671|
   672|
   673|@router.post("/transaction-candidates/{candidate_id}/transfer")
   674|def transaction_candidates_transfer(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   675|    return mark_candidate_transfer(conn, candidate_id, (payload or {}).get("note"))
   676|
   677|
   678|@router.post("/transaction-candidates/{candidate_id}/type")
   679|def transaction_candidates_type(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   680|    return mark_candidate_type(conn, candidate_id, str(payload.get("candidate_type") or ""), (payload or {}).get("note"))
   681|
   682|
   683|@router.post("/transaction-candidates/batch-confirm-safe/preview")
   684|def transaction_candidates_batch_confirm_safe_preview(conn: Connection = Depends(get_db)):
   685|    return preview_safe_auto_candidates(conn)
   686|
   687|
   688|@router.post("/transaction-candidates/batch/preview")
   689|def transaction_candidates_batch_preview(payload: dict, conn: Connection = Depends(get_db)):
   690|    return preview_batch_confirm_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], account_id=str(payload.get("account_id") or ""), budget_year=str(payload.get("budget_year") or "2026"))
   691|
   692|
   693|@router.post("/transaction-candidates/batch")
   694|def transaction_candidates_batch(payload: dict, conn: Connection = Depends(get_db)):
   695|    return batch_mark_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], action=str(payload.get("action") or ""), account_id=payload.get("account_id"), category_id=payload.get("category_id"), preview_id=payload.get("preview_id"), note=payload.get("note"), budget_year=str(payload.get("budget_year") or "2026"))
   696|
   697|
   698|@router.post("/transaction-candidates/archive-reference-year")
   699|def transaction_candidates_archive_reference(payload: dict | None = None, conn: Connection = Depends(get_db)):
   700|    payload = payload or {}
   701|    return archive_reference_year_candidates(conn, active_year=str(payload.get("active_year") or "2026"), reference_year=str(payload.get("reference_year") or "2025"))
   702|
   703|
   704|@router.post("/transaction-candidates/batch-confirm-safe")
   705|def transaction_candidates_batch_confirm_safe(payload: dict, conn: Connection = Depends(get_db)):
   706|    account_id = str(payload.get("account_id") or "")
   707|    return batch_confirm_safe_candidates(conn, account_id=account_id, candidate_ids=payload.get("candidate_ids") or None)
   708|
   709|
   710|@router.post("/transaction-candidates/detect-duplicates")
   711|def transaction_candidates_detect_duplicates(conn: Connection = Depends(get_db)):
   712|    return detect_duplicate_candidates(conn)
   713|
   714|
   715|@router.get("/merchants")
   716|def merchants(include_inactive: bool = False, conn: Connection = Depends(get_db)):
   717|    return list_merchants(conn, include_inactive=include_inactive)
   718|
   719|
   720|@router.post("/merchants")
   721|def merchants_create(payload: dict, conn: Connection = Depends(get_db)):
   722|    return create_merchant(conn, payload)
   723|
   724|
   725|@router.post("/merchants/{merchant_id}/aliases")
   726|def merchants_create_alias(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   727|    return create_merchant_alias(conn, merchant_id, payload)
   728|
   729|
   730|@router.post("/merchants/{merchant_id}/category")
   731|def merchants_update_category(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   732|    return update_merchant_category(conn, merchant_id, payload.get("category_id"))
   733|
   734|
   735|@router.post("/merchants/{merchant_id}/apply")
   736|def merchants_apply(merchant_id: str, conn: Connection = Depends(get_db)):
   737|    return apply_merchant_to_open_candidates(conn, merchant_id)
   738|
   739|
   740|@router.post("/merchants/merge")
   741|def merchants_merge(payload: dict, conn: Connection = Depends(get_db)):
   742|    return merge_merchants(conn, str(payload.get("source_merchant_id") or ""), str(payload.get("target_merchant_id") or ""))
   743|
   744|
   745|@router.post("/seed-candidates/generate")
   746|def seed_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   747|    return seed_budget_candidates_from_rows(conn, payload.get("workbook_rows") or {}, source_file_label=payload.get("source_file_label") or "Budget workbook")
   748|
   749|
   750|@router.post("/seed-candidates/{seed_candidate_id}/preview")
   751|def seed_candidates_preview(seed_candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   752|    return preview_seed_candidate(conn, seed_candidate_id, payload or {})
   753|
   754|
   755|@router.post("/seed-candidates/{seed_candidate_id}/confirm")
   756|def seed_candidates_confirm(seed_candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   757|    return confirm_seed_candidate(conn, seed_candidate_id, payload)
   758|
   759|
   760|

Use offset=760 to continue reading (showing 500-759 of 932 lines)


   300|    return confirm_category_budget_values(conn, category_id, payload)
   301|
   302|
   303|@router.get("/planning/categories/{category_id}/audit")
   304|def budget_planning_category_audit(category_id: str, limit: int = 25, conn: Connection = Depends(get_db)):
   305|    return get_budget_planning_audit_timeline(conn, category_id, limit=limit)
   306|
   307|
   308|@router.post("/planning/batch/preview")
   309|def budget_planning_batch_preview(payload: dict, conn: Connection = Depends(get_db)):
   310|    return preview_budget_planning_batch(conn, payload)
   311|
   312|
   313|@router.post("/planning/batch/confirm")
   314|def budget_planning_batch_confirm(payload: dict, conn: Connection = Depends(get_db)):
   315|    return confirm_budget_planning_batch(conn, payload)
   316|
   317|
   318|@router.post("/planning/excel-template/preview")
   319|def budget_planning_excel_template_preview(payload: dict | None = None, conn: Connection = Depends(get_db)):
   320|    return preview_budget_planning_excel_template(conn, payload or {})
   321|
   322|
   323|@router.get("/recurring")
   324|def recurring_dashboard(today: str | None = None, conn: Connection = Depends(get_db)):
   325|    return get_recurring_dashboard(conn, today=today)
   326|
   327|
   328|@router.post("/recurring/detect")
   329|def recurring_detect(payload: dict | None = None, conn: Connection = Depends(get_db)):
   330|    return detect_recurring_payment_candidates(conn, today=(payload or {}).get("today"))
   331|
   332|
   333|@router.post("/recurring/manual/preview")
   334|def recurring_manual_preview(payload: dict, conn: Connection = Depends(get_db)):
   335|    return preview_manual_recurring_payment(conn, payload)
   336|
   337|
   338|@router.post("/recurring/manual/confirm")
   339|def recurring_manual_confirm(payload: dict, conn: Connection = Depends(get_db)):
   340|    return confirm_manual_recurring_payment(conn, payload)
   341|
   342|
   343|@router.post("/recurring/candidates/{candidate_id}/preview")
   344|def recurring_candidate_preview(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   345|    return preview_recurring_candidate_action(conn, candidate_id, payload)
   346|
   347|
   348|@router.post("/recurring/candidates/{candidate_id}/confirm")
   349|def recurring_candidate_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   350|    body = dict(payload); body.setdefault("candidate_id", candidate_id)
   351|    return confirm_recurring_candidate_action(conn, body)
   352|
   353|
   354|@router.post("/recurring/{recurring_id}/archive")
   355|def recurring_archive(recurring_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   356|    return archive_recurring_payment(conn, recurring_id, payload or {})
   357|
   358|
   359|@router.get("/dashboard/cockpit")
   360|def dashboard_cockpit(month: str = "2026-05", conn: Connection = Depends(get_db)):
   361|    return get_budget_dashboard_cockpit(conn, month=month)
   362|
   363|
   364|@router.get("/analysis/budget-vs-actual")
   365|def budget_vs_actual_analysis(year: str = "2026", conn: Connection = Depends(get_db)):
   366|    return get_budget_vs_actual_analysis(conn, year=year)
   367|
   368|
   369|@router.get("/overview")
   370|def overview(month: str | None = None, conn: Connection = Depends(get_db)):
   371|    return get_budget_overview(conn, month)
   372|
   373|
   374|@router.get("/status/categories")
   375|def category_budget_status(year: str = "2026", conn: Connection = Depends(get_db)):
   376|    return get_budget_status_by_category(conn, year=year)
   377|
   378|
   379|@router.get("/status/categories/{category_id}/months/{month}")
   380|def category_month_detail(category_id: str, month: str, conn: Connection = Depends(get_db)):
   381|    return get_category_month_detail(conn, category_id=category_id, month=month)
   382|
   383|
   384|@router.get("/charts")
   385|def budget_charts(year: str = "2026", conn: Connection = Depends(get_db)):
   386|    return get_budget_charts_2026(conn, year=year)
   387|
   388|
   389|@router.get("/analysis/categories")
   390|def category_analysis(year: str = "2026", month: str | None = None, tx_type: str = "expense", conn: Connection = Depends(get_db)):
   391|    return get_category_analysis_v2(conn, year=year, month=month, tx_type=tx_type)
   392|
   393|
   394|@router.get("/analysis/data-explorer")
   395|def data_explorer(
   396|    year: str = "2026",
   397|    month: str = "",
   398|    date_from: str = "",
   399|    date_to: str = "",
   400|    type: str = "both",
   401|    category_id: str = "",
   402|    merchant: str = "",
   403|    account_id: str = "",
   404|    source_type: str = "",
   405|    tag: str = "",
   406|    amount_min: str = "",
   407|    amount_max: str = "",
   408|    search: str = "",
   409|    recurring_type: str = "",
   410|    conn: Connection = Depends(get_db),
   411|):
   412|    return get_budget_data_explorer(conn, year=year, month=month, date_from=date_from, date_to=date_to, type=type, category_id=category_id, merchant=merchant, account_id=account_id, source_type=source_type, tag=tag, amount_min=amount_min, amount_max=amount_max, search=search, recurring_type=recurring_type)
   413|
   414|
   415|@router.get("/analysis/merchants")
   416|def merchant_analysis(year: str = "2026", month: str = "", merchant: str = "", type: str = "expense", conn: Connection = Depends(get_db)):
   417|    return get_merchant_analysis(conn, year=year, month=month, merchant=merchant, type=type)
   418|
   419|
   420|@router.get("/analysis/budget-deviations")
   421|def budget_deviation_analysis(year: str = "2026", conn: Connection = Depends(get_db)):
   422|    return get_budget_deviation_analysis(conn, year=year)
   423|
   424|
   425|@router.get("/analysis/monthly")
   426|def monthly_analysis(year: str = "2026", conn: Connection = Depends(get_db)):
   427|    return get_monthly_comparison(conn, year=year)
   428|
   429|
   430|@router.get("/analysis/monthly-comparison")
   431|def monthly_comparison(year: str = "2026", conn: Connection = Depends(get_db)):
   432|    return get_monthly_comparison(conn, year=year)
   433|
   434|
   435|@router.get("/income/status")
   436|def income_budget_status(year: str = "2026", conn: Connection = Depends(get_db)):
   437|    return get_income_status(conn, year=year)
   438|
   439|
   440|@router.get("/income/plans")
   441|def income_plans(year: str = "2026", include_archived: bool = False, conn: Connection = Depends(get_db)):
   442|    return list_income_plan_items(conn, year=year, include_archived=include_archived)
   443|
   444|
   445|@router.get("/income/seed-candidates")
   446|def income_seed_candidates(year: str = "2026", conn: Connection = Depends(get_db)):
   447|    return list_income_seed_candidates(conn, year=year)
   448|
   449|
   450|@router.post("/income/plans/preview")
   451|def income_plans_preview(payload: dict, conn: Connection = Depends(get_db)):
   452|    return create_budget_plan_item_preview(conn, payload)
   453|
   454|
   455|@router.post("/income/plans/confirm")
   456|def income_plans_confirm(payload: dict, conn: Connection = Depends(get_db)):
   457|    return confirm_budget_plan_item(conn, payload)
   458|
   459|
   460|@router.post("/income/templates/preview")
   461|def income_templates_preview(payload: dict | None = None, conn: Connection = Depends(get_db)):
   462|    return preview_income_plan_templates(conn, year=str((payload or {}).get("year") or "2026"))
   463|
   464|
   465|@router.post("/income/templates/confirm")
   466|def income_templates_confirm(payload: dict | None = None, conn: Connection = Depends(get_db)):
   467|    return confirm_income_plan_templates(conn, year=str((payload or {}).get("year") or "2026"))
   468|
   469|
   470|@router.post("/income/plans/{plan_item_id}/update/preview")
   471|def income_plans_update_preview(plan_item_id: str, payload: dict, conn: Connection = Depends(get_db)):
   472|    return preview_update_income_plan_item(conn, plan_item_id, payload)
   473|
   474|
   475|@router.post("/income/plans/{plan_item_id}/update/confirm")
   476|def income_plans_update_confirm(plan_item_id: str, payload: dict, conn: Connection = Depends(get_db)):
   477|    return confirm_update_income_plan_item(conn, plan_item_id, payload)
   478|
   479|
   480|

Use offset=480 to continue reading (showing 300-479 of 932 lines)


     1|from __future__ import annotations
     2|
     3|import csv
     4|from dataclasses import dataclass
     5|from io import StringIO
     6|from sqlite3 import Connection
     7|from typing import Any
     8|
     9|from jarvis_finance.services.budget_imports import (
    10|    detect_duplicate_candidates,
    11|    seed_bank_transfer_candidates_from_rows,
    12|    seed_credit_card_candidates_from_rows,
    13|    seed_migros_candidates_from_rows,
    14|)
    15|
    16|
    17|@dataclass(frozen=True)
    18|class ImportProfile:
    19|    name: str
    20|    source_type: str
    21|    filename_patterns: tuple[str, ...]
    22|    delimiter: str
    23|    encoding: str
    24|    date_columns: tuple[str, ...]
    25|    booking_date_columns: tuple[str, ...]
    26|    description_columns: tuple[str, ...]
    27|    amount_columns: tuple[str, ...]
    28|    currency_columns: tuple[str, ...]
    29|    account_columns: tuple[str, ...]
    30|    fingerprint_columns: tuple[str, ...]
    31|    default_rules: tuple[str, ...]
    32|
    33|
    34|IMPORT_PROFILES: dict[str, ImportProfile] = {
    35|    "visa_credit_card": ImportProfile(
    36|        name="visa_credit_card",
    37|        source_type="visa_credit_card",
    38|        filename_patterns=("visa", "kreditkarte", "credit", "card"),
    39|        delimiter=",",
    40|        encoding="utf-8-sig",
    41|        date_columns=("Date", "ValutaDate"),
    42|        booking_date_columns=("ValutaDate",),
    43|        description_columns=("MerchantName", "Details", "MerchantPlace"),
    44|        amount_columns=("Amount",),
    45|        currency_columns=("Currency",),
    46|        account_columns=("CardId",),
    47|        fingerprint_columns=("TransactionId", "CardId", "Date", "Amount", "Currency", "MerchantName"),
    48|        default_rules=("migros_covered_by_migros", "subscriptions_review", "galaxus_review"),
    49|    ),
    50|    "migros_receipts": ImportProfile(
    51|        name="migros_receipts",
    52|        source_type="migros_receipts",
    53|        filename_patterns=("migros", "cumulus"),
    54|        delimiter=";",
    55|        encoding="utf-8-sig",
    56|        date_columns=("Datum",),
    57|        booking_date_columns=("Datum", "Zeit"),
    58|        description_columns=("Filiale", "Artikel"),
    59|        amount_columns=("Umsatz",),
    60|        currency_columns=(),
    61|        account_columns=("Filiale",),
    62|        fingerprint_columns=("Datum", "Zeit", "Filiale", "Kassennummer", "Transaktionsnummer"),
    63|        default_rules=("receipt_level_candidate", "article_rows_detail_only", "food_household_default"),
    64|    ),
    65|    "raiffeisen_bank": ImportProfile(
    66|        name="raiffeisen_bank",
    67|        source_type="raiffeisen_bank",
    68|        filename_patterns=("raiffeisen",),
    69|        delimiter=";",
    70|        encoding="cp1252",
    71|        date_columns=("Booked At", "Valuta Date"),
    72|        booking_date_columns=("Valuta Date",),
    73|        description_columns=("Text",),
    74|        amount_columns=("Credit/Debit Amount",),
    75|        currency_columns=(),
    76|        account_columns=("IBAN",),
    77|        fingerprint_columns=("IBAN", "Booked At", "Text", "Credit/Debit Amount", "Valuta Date"),
    78|        default_rules=("income_detection", "internal_transfer_detection", "true_wealth_investment_transfer", "credit_card_payment"),
    79|    ),
    80|    "akb_bank": ImportProfile(
    81|        name="akb_bank",
    82|        source_type="akb_bank",
    83|        filename_patterns=("akb", "aargauische", "kantonalbank"),
    84|        delimiter=";",
    85|        encoding="utf-8-sig",
    86|        date_columns=("Buchung", "Valuta"),
    87|        booking_date_columns=("Valuta",),
    88|        description_columns=("Buchungstext",),
    89|        amount_columns=("Belastung", "Gutschrift"),
    90|        currency_columns=(),
    91|        account_columns=(),
    92|        fingerprint_columns=("Buchung", "Valuta", "Buchungstext", "Belastung", "Gutschrift"),
    93|        default_rules=("income_detection", "internal_transfer_detection", "credit_card_payment"),
    94|    ),
    95|}
    96|
    97|
    98|def _norm(value: object) -> str:
    99|    return str(value or "").strip().lower().replace("\ufeff", "")
   100|
   101|
   102|def detect_import_profile(columns: list[str]) -> str | None:
   103|    normalized = {_norm(c) for c in columns}
   104|    if {"transactionid", "cardid", "date", "amount", "merchantname"}.issubset(normalized):
   105|        return "visa_credit_card"
   106|    if {"datum", "zeit", "filiale", "transaktionsnummer", "artikel", "umsatz"}.issubset(normalized):
   107|        return "migros_receipts"
   108|    if {"iban", "booked at", "text", "credit/debit amount"}.issubset(normalized):
   109|        return "raiffeisen_bank"
   110|    if {"buchung", "valuta", "buchungstext", "belastung", "gutschrift"}.issubset(normalized):
   111|        return "akb_bank"
   112|    return None
   113|
   114|
   115|def parse_budget_csv_text(csv_text: str, profile_name: str | None = None) -> dict[str, Any]:
   116|    sample = csv_text[:8192]
   117|    delimiter = IMPORT_PROFILES.get(profile_name or "", ImportProfile("", "", (), ";", "utf-8", (), (), (), (), (), (), (), ())).delimiter
   118|    try:
   119|        delimiter = csv.Sniffer().sniff(sample, delimiters=";,\t|").delimiter
   120|    except csv.Error:
   121|        pass
   122|    reader = csv.DictReader(StringIO(csv_text), delimiter=delimiter)
   123|    rows = [dict(r) for r in reader]
   124|    columns = reader.fieldnames or []
   125|    detected = profile_name or detect_import_profile(list(columns))
   126|    if detected is None:
   127|        raise ValueError("unsupported budget CSV profile")
   128|    return {"profile": detected, "delimiter": delimiter, "columns": columns, "rows": rows}
   129|
   130|
   131|def import_profile_specs() -> list[dict[str, Any]]:
   132|    return [p.__dict__ for p in IMPORT_PROFILES.values()]
   133|
   134|
   135|def _existing_fingerprints(conn: Connection) -> set[str]:
   136|    return {str(r["raw_fingerprint"]) for r in conn.execute("SELECT raw_fingerprint FROM budget_transaction_candidates WHERE raw_fingerprint IS NOT NULL").fetchall()}
   137|
   138|
   139|def _result(profile: str, dry_run: bool, parsed_rows: int, seed_result: dict[str, Any] | None = None, already: int = 0) -> dict[str, Any]:
   140|    base = {"profile": profile, "dry_run": dry_run, "parsed_rows": parsed_rows, "already_processed_count": already}
   141|    if seed_result:
   142|        base.update(seed_result)
   143|    base.setdefault("candidate_count", 0)
   144|    base.setdefault("possible_duplicate_count", 0)
   145|    base.setdefault("transfer_candidate_count", 0)
   146|    base.setdefault("income_candidate_count", 0)
   147|    base.setdefault("line_item_count", 0)
   148|    return base
   149|
   150|
   151|def import_budget_csv_text(
   152|    conn: Connection,
   153|    profile_name: str,
   154|    csv_text: str,
   155|    *,
   156|    source_file_label: str,
   157|    dry_run: bool = True,
   158|    default_category_id: str | None = None,
   159|    income_category_id: str | None = None,
   160|) -> dict[str, Any]:
   161|    parsed = parse_budget_csv_text(csv_text, profile_name)
   162|    profile = IMPORT_PROFILES[parsed["profile"]]
   163|    rows = parsed["rows"]
   164|    if dry_run:
   165|        return _result(profile.name, True, len(rows), {"would_create_candidate_count": len(rows)})
   166|
   167|    before = _existing_fingerprints(conn)
   168|    if profile.name == "visa_credit_card":
   169|        seed = seed_credit_card_candidates_from_rows(conn, rows, source_file_label=source_file_label, subscription_category_id=default_category_id, migros_covered=True, candidate_source_type=profile.source_type)
   170|    elif profile.name == "migros_receipts":
   171|        seed = seed_migros_candidates_from_rows(conn, rows, source_file_label=source_file_label, auto_category_id=default_category_id, candidate_source_type=profile.source_type)
   172|    elif profile.name in {"raiffeisen_bank", "akb_bank"}:
   173|        seed = seed_bank_transfer_candidates_from_rows(conn, rows, source_file_label=source_file_label, income_category_id=income_category_id, candidate_source_type=profile.source_type)
   174|    else:  # pragma: no cover - guarded by profile map
   175|        raise ValueError(f"unsupported profile {profile.name}")
   176|
   177|    duplicate_result = detect_duplicate_candidates(conn)
   178|    after = _existing_fingerprints(conn)
   179|    already = max(0, len(before) + len(rows) - len(after))
   180|    if duplicate_result.get("duplicate_count"):
   181|        seed["possible_duplicate_count"] = max(int(seed.get("possible_duplicate_count") or 0), int(duplicate_result["duplicate_count"]))
   182|    return _result(profile.name, False, len(rows), seed, already=already)
   183|


     1|from __future__ import annotations
     2|
     3|import csv
     4|import hashlib
     5|import json
     6|import re
     7|from collections import defaultdict
     8|from dataclasses import dataclass
     9|from datetime import datetime, timedelta
    10|from decimal import Decimal, InvalidOperation
    11|from sqlite3 import Connection
    12|from typing import Any
    13|
    14|from fastapi import HTTPException
    15|
    16|from jarvis_finance.audit.log import record_audit_event
    17|from jarvis_finance.services.budget_common import new_id, now, row_to_dict
    18|from jarvis_finance.services.budget_transactions import confirm_budget_transaction
    19|
    20|SUBSCRIPTION_PATTERNS = ["APPLE", "APPLE.COM", "GOOGLE", "AMAZON PRIME", "NETFLIX", "DISNEY", "DISNEYPLUS"]
    21|MIGROS_PATTERNS = ["MIGROS", "MIGROL", "CUMULUS", "MMM "]
    22|GALAXUS_PATTERNS = ["GALAXUS"]
    23|INCOME_PATTERNS = [
    24|    "LOHN", "SALARY", "ARBEITGEBER", "PAYROLL", "GEHALT",
    25|    "ERNE", "MUSIKSCHULE", "GASSER", "GASSER BAUUNTERNEHMEN",
    26|    "BONUS", "GRATIFIKATION", "SALAER", "RUECKVERGUETUNG", "RÜCKVERGÜTUNG",
    27|    "RUECKERSTATTUNG", "RÜCKERSTATTUNG", "ERSTATTUNG", "SOLARSTROM", "VERGUETUNG", "VERGÜTUNG",
    28|]
    29|STRONG_INCOME_PATTERNS = [
    30|    "LOHN", "SALARY", "GEHALT", "PAYROLL", "ERNE", "MUSIKSCHULE", "GASSER", "GASSER BAUUNTERNEHMEN",
    31|    "BONUS", "GRATIFIKATION", "SOLARSTROM", "RUECKVERGUETUNG", "RÜCKVERGÜTUNG",
    32|]
    33|POSSIBLE_INCOME_PATTERNS = ["RUECKERSTATTUNG", "RÜCKERSTATTUNG", "ERSTATTUNG", "VERGUETUNG", "VERGÜTUNG", "GUTSCHRIFT"]
    34|TRANSFER_PATTERNS = ["RAIFFEISEN", "AKB", "AARGAUISCHE KANTONALBANK", "ÜBERTRAG", "UEBERTRAG", "TRANSFER", "EIGENES KONTO", "KONTOAUSGLEICH", "UMBUCHUNG"]
    35|NEGATIVE_INCOME_PATTERNS = [
    36|    "KREDITKARTE", "VISA", "CREDIT CARD", "KARTENABRECHNUNG", "TRUE WEALTH", "TRUEWEALTH",
    37|    "DEPOT", "INVESTMENT", "WERTSCHRIFT", "SPAREN", "KONTOAUSGLEICH", "UMBUCHUNG",
    38|]
    39|
    40|
    41|def _decimal_text(value: object) -> str | None:
    42|    if value in (None, ""):
    43|        return None
    44|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-")
    45|    text = text.replace("+", "")
    46|    if text.count(",") == 1 and text.count(".") == 0:
    47|        text = text.replace(",", ".")
    48|    text = text.replace(" ", "")
    49|    try:
    50|        return format(abs(Decimal(text)), "f")
    51|    except (InvalidOperation, ValueError):
    52|        return None
    53|
    54|
    55|def _signed_decimal(value: object) -> Decimal | None:
    56|    if value in (None, ""):
    57|        return None
    58|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-").replace("+", "")
    59|    if text.count(",") == 1 and text.count(".") == 0:
    60|        text = text.replace(",", ".")
    61|    text = text.replace(" ", "")
    62|    try:
    63|        return Decimal(text)
    64|    except (InvalidOperation, ValueError):
    65|        return None
    66|
    67|
    68|def _normalise(value: object) -> str:
    69|    return str(value or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("\ufeff", "")
    70|
    71|
    72|def _upper_ascii(value: object) -> str:
    73|    return _normalise(value).upper()
    74|
    75|
    76|def _date_text(value: object) -> str | None:
    77|    text = str(value or "").strip()
    78|    if not text:
    79|        return None
    80|    if re.match(r"^\d{4}-\d{2}-\d{2}", text):
    81|        return text[:10]
    82|    m = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})", text)
    83|    if m:
    84|        d, mo, y = m.groups()
    85|        return f"{y}-{int(mo):02d}-{int(d):02d}"
    86|    return text[:10] if len(text) >= 10 else None
    87|
    88|
    89|def _category_name(conn: Connection, category_id: str | None) -> str | None:
    90|    if not category_id:
    91|        return None
    92|    row = conn.execute("SELECT name FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
    93|    return str(row["name"]) if row else None
    94|
    95|
    96|def _category_by_name(conn: Connection, needles: list[str]) -> tuple[str | None, str | None]:
    97|    cats = conn.execute("SELECT category_id, name FROM budget_categories WHERE is_active=1").fetchall()
    98|    for needle in needles:
    99|        n = _normalise(needle)
   100|        for cat in cats:
   101|            if n and (n in _normalise(cat["name"]) or _normalise(cat["name"]) in n):
   102|                return str(cat["category_id"]), str(cat["name"])
   103|    return None, None
   104|
   105|
   106|def suggest_category(conn: Connection, description: str, source_label: str = "") -> tuple[str | None, str | None, str, bool]:
   107|    text = _normalise(f"{source_label} {description}")
   108|    if "migros" in text or "cumulus" in text:
   109|        cid, cname = _category_by_name(conn, ["Essen & Haushalt", "Grosseinkauf", "Essen", "Haushalt", "Leben"])
   110|        return cid, cname or "Essen & Haushalt / Grosseinkauf", "0.80", cid is None
   111|    keyword_map = [
   112|        (["krankenkasse", "versicherung", "axa", "zurich", "css", "helsana"], ["Versicherungen", "Krankenkasse"]),
   113|        (["coop", "denner", "aldi", "lidl"], ["Essen", "Haushalt", "Leben"]),
   114|        (["sbb", "zug", "bahn", "parking", "tankstelle", "mazda"], ["Mobilität", "Auto"]),
   115|        (["lohn", "salary", "einkommen", "gutschrift"], ["Einnahmen", "Lohn"]),
   116|    ]
   117|    for words, cats in keyword_map:
   118|        if any(w in text for w in words):
   119|            cid, cname = _category_by_name(conn, cats)
   120|            return cid, cname or cats[0], "0.65", cid is None
   121|    return None, None, "0.20", True
   122|
   123|
   124|def _duplicate_of(conn: Connection, tx_date: str | None, amount: str | None, description: str) -> str | None:
   125|    if not tx_date or not amount:
   126|        return None
   127|    row = conn.execute(
   128|        """
   129|        SELECT budget_transaction_id FROM budget_transactions
   130|        WHERE transaction_date=? AND status IN ('confirmed','reversed')
   131|          AND COALESCE(amount_chf, amount_original)=?
   132|          AND lower(description)=lower(?)
   133|        ORDER BY created_at LIMIT 1
   134|        """,
   135|        (tx_date, amount, description),
   136|    ).fetchone()
   137|    return str(row["budget_transaction_id"]) if row else None
   138|
   139|
   140|def _candidate_key(source_label: str, row_ref: str, tx_date: str | None, amount: str | None, description: str) -> str:
   141|    raw = "|".join([source_label, row_ref, tx_date or "", amount or "", description])
   142|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
   143|
   144|
   145|STATUS_LABELS = {
   146|    "pending": "Offen",
   147|    "auto_categorized": "Sicher vorgeschlagen",
   148|    "needs_review": "Review nötig",
   149|    "confirmed": "Bestätigt",
   150|    "ignored": "Ignoriert",
   151|    "covered_by_source": "Durch Quelle abgedeckt",
   152|    "covered_by_migros": "Durch Migros abgedeckt",
   153|    "duplicate_candidate": "Mögliches Duplikat",
   154|    "duplicate": "Mögliches Duplikat",
   155|    "transfer_candidate": "Möglicher Transfer",
   156|    "superseded": "Ersetzt",
   157|}
   158|
   159|
   160|def _candidate_view(row) -> dict[str, Any]:
   161|    data = row_to_dict(row) | {"requires_review": bool(row["requires_review"])}
   162|    data["status_label"] = data.get("status_label") or STATUS_LABELS.get(str(data.get("status") or ""), str(data.get("status") or ""))
   163|    return data
   164|
   165|
   166|def _normalized_merchant_name(value: object) -> str:
   167|    return re.sub(r"[^a-z0-9]+", " ", _normalise(value)).strip()
   168|
   169|
   170|def _merchant_text(row_or_description: Any, source_type: str | None = None) -> str:
   171|    if isinstance(row_or_description, dict):
   172|        return _normalise(" ".join(str(row_or_description.get(k) or "") for k in ("merchant", "description", "source_file_label")))
   173|    return _normalise(row_or_description)
   174|
   175|
   176|def _merchant_alias_matches(alias, candidate) -> bool:
   177|    if alias["source_type"] and alias["source_type"] != candidate["source_type"]:
   178|        return False
   179|    text = _merchant_text(row_to_dict(candidate))
   180|    pattern = str(alias["pattern"] or "")
   181|    norm_pattern = _normalise(pattern)
   182|    if alias["match_type"] == "exact":
   183|        return text == norm_pattern or _normalise(candidate["merchant"] or "") == norm_pattern
   184|    if alias["match_type"] == "regex":
   185|        try:
   186|            return re.search(pattern, text, re.IGNORECASE) is not None
   187|        except re.error:
   188|            return False
   189|    return norm_pattern in text
   190|
   191|
   192|def _fingerprint(parts: list[object]) -> str:
   193|    raw = "|".join(str(p or "").strip() for p in parts)
   194|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
   195|
   196|
   197|def _insert_candidate(conn: Connection, row: dict[str, Any]) -> bool:
   198|    existing = conn.execute(
   199|        """
   200|        SELECT 1 FROM budget_transaction_candidates
   201|        WHERE source_file_label=? AND source_row_or_range=? AND transaction_date IS ?
   202|          AND description=? AND COALESCE(amount_original,'')=COALESCE(?, '')
   203|        """,
   204|        (row["source_file_label"], row.get("source_row_or_range"), row.get("transaction_date"), row["description"], row.get("amount_original")),
   205|    ).fetchone()
   206|    if existing:
   207|        return False
   208|    conn.execute(
   209|        """
   210|        INSERT INTO budget_transaction_candidates(
   211|          transaction_candidate_id, source_file_label, source_row_or_range, source_type,
   212|          transaction_date, description, merchant, amount_original, currency_original,
   213|          proposed_category_id, proposed_category_name, duplicate_of_transaction_id,
   214|          confidence, requires_review, status, notes, created_at, updated_at,
   215|          classification, review_reason, rule_id, rule_name, source_priority, covered_by_source,
   216|          receipt_key, linked_candidate_id, account_source, raw_fingerprint
   217|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   218|        """,
   219|        (
   220|            row["transaction_candidate_id"], row["source_file_label"], row.get("source_row_or_range"), row.get("source_type") or "csv_seed",
   221|            row.get("transaction_date"), row["description"], row.get("merchant"), row.get("amount_original"), row.get("currency_original") or "CHF",
   222|            row.get("proposed_category_id"), row.get("proposed_category_name"), row.get("duplicate_of_transaction_id"),
   223|            row.get("confidence") or "0", 1 if row.get("requires_review", True) else 0, row.get("status") or "pending", row.get("notes"), row["created_at"], row["updated_at"],
   224|            row.get("classification"), row.get("review_reason"), row.get("rule_id"), row.get("rule_name"), row.get("source_priority") or 50, row.get("covered_by_source"),
   225|            row.get("receipt_key"), row.get("linked_candidate_id"), row.get("account_source"), row.get("raw_fingerprint"),
   226|        ),
   227|    )
   228|    return True
   229|
   230|
   231|def seed_transaction_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]] | list[list[Any]], *, source_file_label: str, source_type: str = "csv_seed") -> dict[str, int]:
   232|    created = duplicates = review = suggested = ignored = confirmed = 0
   233|    for idx, raw in enumerate(rows, start=1):
   234|        if isinstance(raw, dict):
   235|            low = {_normalise(k): v for k, v in raw.items()}
   236|            description = str(low.get("beschreibung") or low.get("description") or low.get("text") or low.get("artikel") or low.get("merchant") or low.get("merchantname") or low.get("haendler") or "").strip()
   237|            merchant = str(low.get("merchant") or low.get("merchantname") or low.get("filiale") or low.get("haendler") or "").strip() or None
   238|            tx_date = _date_text(low.get("datum") or low.get("date") or low.get("buchungsdatum") or low.get("valutadate"))
   239|            amount = _decimal_text(low.get("betrag") or low.get("amount") or low.get("umsatz") or low.get("wert") or low.get("originalamount"))
   240|        else:
   241|            cells = [str(c).strip() for c in raw]
   242|            description = next((c for c in cells if c and _decimal_text(c) is None and not c[:4].isdigit()), "")
   243|            merchant = None
   244|            tx_date = next((_date_text(c) for c in cells if _date_text(c)), None)
   245|            amount = next((_decimal_text(c) for c in cells if _decimal_text(c) is not None), None)
   246|        if not description or not amount:
   247|            ignored += 1
   248|            continue
   249|        cid, cname, conf, needs_review = suggest_category(conn, description, source_file_label)
   250|        dup = _duplicate_of(conn, tx_date, amount, description)
   251|        status = "duplicate" if dup else ("needs_review" if needs_review else "pending")
   252|        if dup:
   253|            duplicates += 1
   254|        if cid:
   255|            suggested += 1
   256|        if needs_review:
   257|            review += 1
   258|        ts = now()
   259|        row_ref = f"R{idx}"
   260|        inserted = _insert_candidate(conn, {
   261|

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


Explanation:
**Evaluating file search options**

I’m thinking about using a regex tool, but maybe the file is too big for that? I’m considering if I’d be allowed to use grep via the terminal since I’m facing issues with search_files failing in a strange way. Perhaps writing a Python script to read through the file could be another option. I’m curious to see which method might work best for my needs!

from pathlib import Path
p=Path('/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py')
text=p.read_text()
for term in ['def list_transaction_candidates','def review_candidate_summary','import_session','covered_by_source','already_processed','superseded']:
    print('\nTERM',term)
    for i,line in enumerate(text.splitlines(),1):
        if term in line:
            print(i,line[:200])


Output:
TERM def list_transaction_candidates
755 def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rul

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

TERM import_session

TERM covered_by_source
151     "covered_by_source": "Durch Quelle abgedeckt",
215           classification, review_reason, rule_id, rule_name, source_priority, covered_by_source,
224             row.get("classification"), row.get("review_reason"), row.get("rule_id"), row.get("rule_name"), row.get("source_priority") or 50, row.get("covered_by_source"),
600                 classification=?, review_reason=?, rule_id=?, rule_name=?, covered_by_source=?, updated_at=?
646             "covered_by_source": covered_by, "raw_fingerprint": _fingerprint([source_file_label, row_ref, tx_date, amount, desc]), "created_at": ts, "updated_at": ts,
762         clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','superseded','reference_2025','archived_reference')"
778             "covered_by_source": "status IN ('covered_by_migros','covered_by_source')",
791             "ignored": "status IN ('ignored','covered_by_migros','covered_by_source','superseded','reference_2025','archived_reference')",
829           AND status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','reference_2025','archived_reference')
923     if row["status"] in {"covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "ignored", "reference_2025", "archived_reference"}:
1037         AND COALESCE(covered_by_source, '')=''
1102 BLOCKED_BULK_EDIT_STATUSES = {"confirmed", "covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference"}
1111     if status in {"covered_by_source", "covered_by_migros"}:
1135         "covered_by_source": count("covered_by_source") + count("covered_by_migros"),
1204                 conn.execute("UPDATE budget_transaction_candidates SET status='covered_by_migros', classification='covered_by_migros', covered_by_source='migros_csv', requires_review=0, review_reason=
1297     clauses = ["status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','reference_2025','archived_reference')", "lower(COALESCE(merchant,'')
1379           (SELECT COUNT(*) FROM budget_transaction_candidates t WHERE t.merchant_id=m.merchant_id AND t.status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','auto_ignored_dupli
1417     return conn.execute("SELECT * FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','auto_ignored_duplicate','superseded','reference
1479         WHERE status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','auto_ignored_duplicate','superseded','reference_2025','archived_reference')

TERM already_processed

TERM superseded
156     "superseded": "Ersetzt",
397     open_count = sum(count for status, count in by_status.items() if status not in {"confirmed", "ignored", "superseded", "reference_2025", "archived_reference"})
420           AND status NOT IN ('superseded','ignored','reference_2025','archived_reference')
447           AND status NOT IN ('confirmed','ignored','covered_by_migros','superseded','reference_2025','archived_reference')
538           AND status NOT IN ('confirmed','ignored','superseded')
762         clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','superseded','reference_2025','archived_reference')"
791             "ignored": "status IN ('ignored','covered_by_migros','covered_by_source','superseded','reference_2025','archived_reference')",
829           AND status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','reference_2025','archived_reference')
923     if row["status"] in {"covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "ignored", "reference_2025", "archived_reference"}:
1087     rows = conn.execute("SELECT status, classification, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirm
1102 BLOCKED_BULK_EDIT_STATUSES = {"confirmed", "covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference"}
1113     if status in {"auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference", "confirmed", "ignored"}:
1222     if row["status"] in {"confirmed", "ignored", "covered_by_migros", "superseded"}:
1297     clauses = ["status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','reference_2025','archived_reference')", "lower(COALESCE(merchant,'')
1379           (SELECT COUNT(*) FROM budget_transaction_candidates t WHERE t.merchant_id=m.merchant_id AND t.status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','auto_ignored_dupli
1417     return conn.execute("SELECT * FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','auto_ignored_duplicate','superseded','reference
1479         WHERE status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','auto_ignored_duplicate','superseded','reference_2025','archived_reference')
1520     rows = conn.execute("SELECT * FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirmed','reference_2025','archived_reference','superseded')", (referenc
1540         return {"article_candidate_count": count, "superseded_count": 0, "dry_run": 1}
1542     conn.execute(f"UPDATE budget_transaction_candidates SET status='superseded', review_reason='superseded_by_migros_receipt_aggregation', updated_at=? WHERE {where}", (ts,))
1543     audit_id = record_audit_event(conn, source="budget_phase15_cleanup", action="cleanup_migros_article_candidates", entity_type="budget_transaction_candidate", entity_id="migros_article_candidates",
1545     return {"article_candidate_count": count, "superseded_count": count, "audit_id": audit_id, "dry_run": 0}


   740|        ts = now(); row_ref = f"R{br.idx}"
   741|        if _insert_candidate(conn, {
   742|            "transaction_candidate_id": "btxcand_" + _candidate_key(source_file_label, row_ref, br.date, br.amount_abs, br.desc),
   743|            "source_file_label": source_file_label, "source_row_or_range": row_ref, "source_type": candidate_source_type,
   744|            "transaction_date": br.date, "description": br.desc[:300], "merchant": None, "amount_original": br.amount_abs, "currency_original": "CHF",
   745|            "proposed_category_id": category_id, "proposed_category_name": category_name, "duplicate_of_transaction_id": dup,
   746|            "confidence": conf, "requires_review": needs_review, "status": status, "notes": json.dumps({"transaction_type": tx_type, "reason": reason}, ensure_ascii=False),
   747|            "classification": classification, "review_reason": reason, "rule_id": classification, "source_priority": 80,
   748|            "account_source": br.account, "raw_fingerprint": _fingerprint([source_file_label, row_ref, br.date, br.amount_abs, br.desc, br.account]), "created_at": ts, "updated_at": ts,
   749|        }):
   750|            created += 1
   751|    conn.commit()
   752|    return {"candidate_count": created, "income_candidate_count": income, "transfer_candidate_count": transfer, "investment_transfer_count": investment_transfer, "credit_card_payment_count": credit_card_payment, "review_required_count": review, "possible_duplicate_count": dup_count}
   753|
   754|
   755|def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False) -> list[dict[str, Any]]:
   756|    clauses=[]; params=[]
   757|    if budget_year:
   758|        clauses.append("substr(transaction_date,1,4)=?"); params.append(budget_year)
   759|    if status:
   760|        clauses.append("status=?"); params.append(status)
   761|    elif not include_reference and not tab:
   762|        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','superseded','reference_2025','archived_reference')")
   763|    if tab:
   764|        mapping = {
   765|            "auto": "requires_review=0 AND status IN ('pending','auto_categorized')",
   766|            "review": "requires_review=1 AND status IN ('needs_review','pending','transfer_candidate')",
   767|            "subscriptions": "classification='subscription_auto'",
   768|            "galaxus": "classification='galaxus_review'",
   769|            "migros_over_50": "classification='migros_review_over_threshold'",
   770|            "health": "classification='health_medical'",
   771|            "auto_transport": "classification='auto_transport'",
   772|            "shopping": "classification IN ('shopping','galaxus_review')",
   773|            "pets": "classification='pets'",
   774|            "unclear": "classification IN ('admin_other','unclear','legacy_csv_seed','card_uncategorized')",
   775|            "covered_by_migros": "status='covered_by_migros'",
   776|            "duplicates": "status IN ('duplicate','duplicate_candidate')",
   777|            "auto_ignored_duplicates": "status='auto_ignored_duplicate'",
   778|            "covered_by_source": "status IN ('covered_by_migros','covered_by_source')",
   779|            "transfers": "classification='transfer_candidate' OR status='transfer_candidate'",
   780|            "bank": "source_type IN ('bank_csv','raiffeisen_bank','akb_bank')",
   781|            "visa": "source_type='visa_credit_card' OR source_type='credit_card_csv'",
   782|            "migros": "source_type IN ('migros_receipts','migros_receipt') OR status='covered_by_migros'",
   783|            "raiffeisen": "source_type='raiffeisen_bank'",
   784|            "akb": "source_type='akb_bank'",
   785|            "income": "classification='income_candidate'",
   786|            "possible_income": "classification='possible_income'",
   787|            "money_in": "classification IN ('income_candidate','possible_income')",
   788|            "not_income_transfer": "classification IN ('transfer_candidate','investment_transfer','credit_card_payment') OR status='transfer_candidate'",
   789|            "credit_card_payment": "classification='credit_card_payment'",
   790|            "investment_transfers": "classification='investment_transfer'",
   791|            "ignored": "status IN ('ignored','covered_by_migros','covered_by_source','superseded','reference_2025','archived_reference')",
   792|        }
   793|        if tab in mapping:
   794|            clauses.append(mapping[tab])
   795|    if source_type:
   796|        clauses.append("source_type=?"); params.append(source_type)
   797|    if category_id:
   798|        clauses.append("proposed_category_id=?"); params.append(category_id)
   799|    if merchant:
   800|        clauses.append("lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"); params.append(f"%{merchant.lower()}%")
   801|    if rule:
   802|        clauses.append("lower(COALESCE(rule_id,'') || ' ' || COALESCE(rule_name,'') || ' ' || COALESCE(review_reason,'')) LIKE ?"); params.append(f"%{rule.lower()}%")
   803|    if min_confidence:
   804|        clauses.append("CAST(COALESCE(confidence,'0') AS REAL) >= CAST(? AS REAL)"); params.append(min_confidence)
   805|    if date_from:
   806|        clauses.append("transaction_date>=?"); params.append(date_from)
   807|    if date_to:
   808|        clauses.append("transaction_date<=?"); params.append(date_to)
   809|    if amount_min:
   810|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) >= CAST(? AS REAL)"); params.append(amount_min)
   811|    if amount_max:
   812|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) <= CAST(? AS REAL)"); params.append(amount_max)
   813|    where = "WHERE " + " AND ".join(clauses) if clauses else ""
   814|    sort_map = {"date": "transaction_date", "merchant": "lower(COALESCE(merchant, description))", "category": "proposed_category_name", "confidence": "CAST(COALESCE(confidence,'0') AS REAL)", "source": "source_type", "status": "status", "created_at": "created_at"}
   815|    order_col = sort_map.get(sort_by, "created_at")
   816|    direction = "ASC" if sort_dir.lower() == "asc" else "DESC"
   817|    rows = conn.execute(f"SELECT * FROM budget_transaction_candidates {where} ORDER BY {order_col} {direction}, created_at DESC LIMIT 500", params).fetchall()
   818|    return [_candidate_view(r) for r in rows]
   819|
   820|
   821|def reclassify_bank_income_candidates(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
   822|    """Re-score existing AKB/Raiffeisen candidates without confirming bookings."""
   823|    cid_income, cname_income = _category_by_name(conn, ["Lohn Marcel", "Einnahmen", "Sonstige Einnahmen", "Rückerstattungen"])
   824|    rows = conn.execute(
   825|        """
   826|        SELECT * FROM budget_transaction_candidates
   827|        WHERE source_type IN ('akb_bank','raiffeisen_bank','bank_csv')
   828|          AND substr(COALESCE(transaction_date,''),1,4)=?
   829|          AND status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','reference_2025','archived_reference')
   830|        """,
   831|        (budget_year,),
   832|    ).fetchall()
   833|    counts = {"scanned_count": len(rows), "income_candidate_count": 0, "possible_income_count": 0, "transfer_candidate_count": 0, "investment_transfer_count": 0, "credit_card_payment_count": 0, "unclear_money_in_count": 0, "updated_count": 0}
   834|    ts = now()
   835|    for row in rows:
   836|        meta = {}
   837|        try:
   838|            meta = json.loads(row["notes"] or "{}") if str(row["notes"] or "").startswith("{") else {}
   839|        except json.JSONDecodeError:
   840|            meta = {}
   841|        is_positive = str(meta.get("transaction_type") or "").lower() == "income"
   842|        decision = _bank_candidate_decision(str(row["description"] or ""), is_positive=is_positive)
   843|        classification = str(decision["classification"])
   844|        if is_positive and classification == "bank_review":
   845|            counts["unclear_money_in_count"] += 1
   846|        if classification == "income_candidate": counts["income_candidate_count"] += 1
   847|        if classification == "possible_income": counts["possible_income_count"] += 1
   848|        if classification in {"transfer_candidate", "investment_transfer", "credit_card_payment"}: counts["transfer_candidate_count"] += 1
   849|        if classification == "investment_transfer": counts["investment_transfer_count"] += 1
   850|        if classification == "credit_card_payment": counts["credit_card_payment_count"] += 1
   851|        proposed_category_id = cid_income if classification in {"income_candidate", "possible_income"} else row["proposed_category_id"]
   852|        proposed_category_name = cname_income if classification in {"income_candidate", "possible_income"} else row["proposed_category_name"]
   853|        notes = json.dumps({"transaction_type": decision["transaction_type"], "reason": decision["review_reason"], "reclassified_by": "income_csv_recognition_v1"}, ensure_ascii=False)
   854|        before = (row["status"], row["classification"], row["review_reason"], row["proposed_category_id"])
   855|        after = (decision["status"], classification, decision["review_reason"], proposed_category_id)
   856|        if before != after:
   857|            conn.execute(
   858|                """
   859|                UPDATE budget_transaction_candidates
   860|                SET status=?, classification=?, requires_review=?, review_reason=?, confidence=?, notes=?, proposed_category_id=?, proposed_category_name=?, updated_at=?
   861|                WHERE transaction_candidate_id=?
   862|                """,
   863|                (decision["status"], classification, 1 if decision["requires_review"] else 0, decision["review_reason"], decision["confidence"], notes, proposed_category_id, proposed_category_name, ts, row["transaction_candidate_id"]),
   864|            )
   865|            counts["updated_count"] += 1
   866|    audit_id = record_audit_event(conn, source="vue_dashboard", action="bank_income_candidates_reclassified", entity_type="budget_transaction_candidate", entity_id=f"bank_income_reclassify_{budget_year}", new_values=counts, created_by="system")
   867|    conn.commit()
   868|    counts["audit_written"] = 1 if audit_id else 0
   869|    return counts
   870|
   871|
   872|def preview_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
   873|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   874|    if not row:
   875|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   876|    payload = payload or {}
   877|    cat_id = payload.get("category_id") or row["proposed_category_id"]
   878|    notes = row["notes"] or ""
   879|    tx_type = payload.get("transaction_type") or "expense"
   880|    try:
   881|        meta = json.loads(notes) if notes and notes.startswith("{") else {}
   882|        tx_type = payload.get("transaction_type") or meta.get("transaction_type") or tx_type
   883|    except json.JSONDecodeError:
   884|        pass
   885|    if not cat_id and tx_type in {"expense", "income"}:
   886|        raise HTTPException(status_code=422, detail="category required before confirm")
   887|    if cat_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat_id,)).fetchone():
   888|        raise HTTPException(status_code=422, detail="active category required before confirm")
   889|    if not payload.get("account_id"):
   890|        raise HTTPException(status_code=422, detail="account_id required before confirm")
   891|    if not row["transaction_date"]:
   892|        raise HTTPException(status_code=422, detail="transaction date required before confirm")
   893|    if not row["amount_original"]:
   894|        raise HTTPException(status_code=422, detail="amount required before confirm")
   895|    if not row["currency_original"]:
   896|        raise HTTPException(status_code=422, detail="currency required before confirm")
   897|    return {
   898|        "preview_id": new_id("preview"),
   899|        "summary": "Buchungskandidat übernehmen",
   900|        "warnings": ["Möglicher Duplikat-Treffer"] if row["duplicate_of_transaction_id"] else ([] if cat_id or tx_type == "transfer" else ["Kategorie fehlt"]),
   901|        "payload": {
   902|            "account_id": payload.get("account_id"),
   903|            "transaction_type": tx_type,
   904|            "transaction_date": payload.get("transaction_date") or row["transaction_date"],
   905|            "description": payload.get("description") or row["description"],
   906|            "payee": payload.get("payee") or row["merchant"],
   907|            "amount_original": row["amount_original"],
   908|            "currency_original": row["currency_original"],
   909|            "category_id": cat_id,
   910|            "source_type": "import_candidate",
   911|            "source_candidate_id": candidate_id,
   912|            "notes": f"Confirmed from transaction candidate {candidate_id}",
   913|        },
   914|    }
   915|
   916|
   917|def confirm_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   918|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   919|    if not row:
   920|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   921|    if row["status"] == "confirmed":
   922|        raise HTTPException(status_code=409, detail="candidate already confirmed")
   923|    if row["status"] in {"covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "ignored", "reference_2025", "archived_reference"}:
   924|        raise HTTPException(status_code=409, detail="candidate is not confirmable")
   925|    if row["status"] in {"duplicate", "duplicate_candidate"} and not payload.get("allow_duplicate"):
   926|        raise HTTPException(status_code=409, detail="duplicate candidate requires explicit allow_duplicate")
   927|    if str(row["transaction_date"] or "")[:4] != str(payload.get("budget_year") or "2026"):
   928|        raise HTTPException(status_code=409, detail="candidate outside active budget year")
   929|    preview = preview_transaction_candidate(conn, candidate_id, payload)
   930|    result = confirm_budget_transaction(conn, preview["payload"])
   931|    ts = now()
   932|    conn.execute("UPDATE budget_transaction_candidates SET status='confirmed', confirmed_transaction_id=?, confirmed_at=?, confirmed_by='user', updated_at=? WHERE transaction_candidate_id=?", (result["entity_id"], ts, ts, candidate_id))
   933|    record_audit_event(conn, source="vue_dashboard", action="budget_transaction_created", entity_type="budget_transaction", entity_id=result["entity_id"], new_values={"source_candidate_id": candidate_id}, created_by="user")
   934|    audit_id = record_audit_event(conn, source="vue_dashboard", action="candidate_confirmed", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"]}, new_values={"status": "confirmed", "confirmed_transaction_id": result["entity_id"], "category_id": preview["payload"].get("category_id")}, created_by="user")
   935|    conn.commit()
   936|    return {"status": "confirmed", "entity_id": result["entity_id"], "audit_id": audit_id, "message": "Buchungskandidat übernommen"}
   937|
   938|
   939|def ignore_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   940|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   941|    if not row:
   942|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   943|    ts = now()
   944|    conn.execute("UPDATE budget_transaction_candidates SET status='ignored', notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (note, ts, candidate_id))
   945|    audit_id = record_audit_event(conn, source="vue_dashboard", action="ignore", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"]}, new_values={"status": "ignored"}, created_by="user")
   946|    conn.commit()
   947|    return {"status": "ignored", "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat ignoriert"}
   948|
   949|
   950|def reopen_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   951|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   952|    if not row:
   953|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   954|    if row["status"] == "confirmed":
   955|        raise HTTPException(status_code=409, detail="confirmed candidates cannot be reopened")
   956|    new_status = "needs_review" if row["requires_review"] else "pending"
   957|    ts = now()
   958|    conn.execute("UPDATE budget_transaction_candidates SET status=?, review_reason=COALESCE(review_reason, 'user_reopened'), notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (new_status, note, ts, candidate_id))
   959|    audit_id = record_audit_event(conn, source="vue_dashboard", action="reopen", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"]}, new_values={"status": new_status}, user_text_note=note, created_by="user")
   960|    conn.commit()
   961|    return {"status": new_status, "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat wieder geöffnet"}
   962|
   963|
   964|def update_transaction_candidate_category(conn: Connection, candidate_id: str, category_id: str, note: str | None = None) -> dict[str, Any]:
   965|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   966|    if not row:
   967|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   968|    if not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone():
   969|        raise HTTPException(status_code=422, detail="active category not found")
   970|    cname = _category_name(conn, category_id)
   971|    ts = now()
   972|    conn.execute("UPDATE budget_transaction_candidates SET proposed_category_id=?, proposed_category_name=?, requires_review=0, status='pending', review_reason='user_category_override', updated_at=? WHERE transaction_candidate_id=?", (category_id, cname, ts, candidate_id))
   973|    audit_id = record_audit_event(conn, source="vue_dashboard", action="update_category", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values=row_to_dict(row), new_values={"category_id": category_id, "category_name": cname}, user_text_note=note, created_by="user")
   974|    conn.commit()
   975|    return {"status": "updated", "entity_id": candidate_id, "audit_id": audit_id}
   976|
   977|
   978|def create_candidate_split(conn: Connection, candidate_id: str, splits: list[dict[str, Any]]) -> dict[str, Any]:
   979|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   980|    if not row:
   981|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   982|    if not splits:
   983|        raise HTTPException(status_code=422, detail="splits required")
   984|    ts = now()
   985|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
   986|    for split in splits:
   987|        amount = _decimal_text(split.get("amount_original"))
   988|        if not amount:
   989|            raise HTTPException(status_code=422, detail="split amount required")
   990|        conn.execute("INSERT INTO budget_candidate_splits(split_id, transaction_candidate_id, category_id, amount_original, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", (new_id("bcsplit"), candidate_id, split.get("category_id"), amount, split.get("notes"), ts, ts))
   991|    conn.execute("UPDATE budget_transaction_candidates SET requires_review=1, status='needs_review', review_reason='split_pending_confirm', updated_at=? WHERE transaction_candidate_id=?", (ts, candidate_id))
   992|    audit_id = record_audit_event(conn, source="vue_dashboard", action="split", entity_type="budget_transaction_candidate_split", entity_id=candidate_id, new_values={"split_count": len(splits)}, created_by="user")
   993|    conn.commit()
   994|    return {"status": "split_created", "entity_id": candidate_id, "split_count": len(splits), "audit_id": audit_id}
   995|
   996|
   997|def mark_candidate_type(conn: Connection, candidate_id: str, candidate_type: str, note: str | None = None) -> dict[str, Any]:
   998|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   999|    if not row:
  1000|        raise HTTPException(status_code=404, detail="transaction candidate not found")
  1001|    allowed = {
  1002|        "transfer": ("transfer_candidate", "transfer_candidate", "user_marked_transfer", "mark_transfer"),
  1003|        "investment_transfer": ("transfer_candidate", "investment_transfer", "user_marked_investment_transfer", "mark_investment_transfer"),
  1004|        "credit_card_payment": ("transfer_candidate", "credit_card_payment", "user_marked_credit_card_payment", "mark_credit_card_payment"),
  1005|    }
  1006|    if candidate_type not in allowed:
  1007|        raise HTTPException(status_code=422, detail="unsupported candidate type")
  1008|    status, classification, reason, action = allowed[candidate_type]
  1009|    ts = now()
  1010|    conn.execute("UPDATE budget_transaction_candidates SET status=?, classification=?, requires_review=1, review_reason=?, notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (status, classification, reason, note, ts, candidate_id))
  1011|    audit_id = record_audit_event(conn, source="vue_dashboard", action=action, entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"], "classification": row["classification"]}, new_values={"status": status, "classification": classification}, user_text_note=note, created_by="user")
  1012|    conn.commit()
  1013|    return {"status": status, "classification": classification, "entity_id": candidate_id, "audit_id": audit_id}
  1014|
  1015|
  1016|def mark_candidate_transfer(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
  1017|    return mark_candidate_type(conn, candidate_id, "transfer", note)
  1018|
  1019|
  1020|def bulk_update_transaction_candidate_category(conn: Connection, *, candidate_ids: list[str], category_id: str, note: str | None = None) -> dict[str, int]:
  1021|    updated = skipped = 0
  1022|    for cid in candidate_ids:
  1023|        row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1024|        if not row or row["status"] == "confirmed":
  1025|            skipped += 1
  1026|            continue
  1027|        update_transaction_candidate_category(conn, cid, category_id, note or "Bulk category assignment")
  1028|        updated += 1
  1029|    return {"updated_count": updated, "skipped_count": skipped}
  1030|
  1031|
  1032|def _safe_auto_candidate_where() -> str:
  1033|    return """
  1034|        status IN ('pending','auto_categorized')
  1035|        AND COALESCE(requires_review, 0)=0
  1036|        AND duplicate_of_transaction_id IS NULL
  1037|        AND COALESCE(covered_by_source, '')=''
  1038|        AND (
  1039|            classification='subscription_auto'
  1040|            OR classification IN ('migros_auto_under_threshold','migros_receipt_food_household')
  1041|            OR classification='auto_transport'
  1042|        )
  1043|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%galaxus%'
  1044|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%digitec%'
  1045|    """
  1046|
  1047|
  1048|def preview_safe_auto_candidates(conn: Connection) -> dict[str, Any]:
  1049|    rows = conn.execute(
  1050|        f"""
  1051|        SELECT classification, COUNT(*) AS count
  1052|        FROM budget_transaction_candidates
  1053|        WHERE {_safe_auto_candidate_where()}
  1054|        GROUP BY classification
  1055|        ORDER BY classification
  1056|        """
  1057|    ).fetchall()
  1058|    total = sum(int(r["count"] or 0) for r in rows)
  1059|    return {
  1060|        "preview_id": new_id("preview"),
  1061|        "summary": f"{total} sichere Auto-Kandidaten bereit für Confirm",
  1062|        "count": total,
  1063|        "groups": [row_to_dict(r) for r in rows],
  1064|        "warnings": ["Preview only: Confirm benötigt Zielkonto und bucht nur sichere Kandidaten."],
  1065|    }
  1066|
  1067|
  1068|def batch_confirm_safe_candidates(conn: Connection, *, account_id: str, candidate_ids: list[str] | None = None) -> dict[str, int]:
  1069|    if not account_id:
  1070|        raise HTTPException(status_code=422, detail="account_id required")
  1071|    allowed_rows = conn.execute(f"SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE {_safe_auto_candidate_where()}").fetchall()
  1072|    allowed_ids = {str(r["transaction_candidate_id"]) for r in allowed_rows}
  1073|    requested_ids = set(candidate_ids or allowed_ids)
  1074|    confirmed = skipped = 0
  1075|    for cid in sorted(requested_ids):
  1076|        if cid not in allowed_ids:
  1077|            skipped += 1
  1078|            continue
  1079|        confirm_transaction_candidate(conn, cid, {"account_id": account_id})
  1080|        confirmed += 1
  1081|    record_audit_event(conn, source="vue_dashboard", action="batch_confirm_safe_candidates", entity_type="budget_transaction_candidate", entity_id="batch", new_values={"confirmed_count": confirmed, "skipped_count": skipped}, created_by="user")
  1082|    conn.commit()
  1083|    return {"confirmed_count": confirmed, "skipped_count": skipped}
  1084|
  1085|
  1086|def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
  1087|    rows = conn.execute("SELECT status, classification, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirmed','ignored','superseded','reference_2025','archived_reference')", (budget_year,)).fetchall()
  1088|    def count(pred):
  1089|        return sum(1 for r in rows if pred(r))
  1090|    return {
  1091|        "open_total": count(lambda r: r["status"] != "covered_by_migros"),
  1092|        "auto_categorized": count(lambda r: r["status"] in {"pending", "auto_categorized"} and not bool(r["requires_review"])),
  1093|        "review_needed": count(lambda r: bool(r["requires_review"])),
  1094|        "galaxus_digitec": count(lambda r: r["classification"] == "galaxus_review"),
  1095|        "migros_over_50": count(lambda r: r["classification"] == "migros_review_over_threshold"),
  1096|        "covered_by_migros": count(lambda r: r["status"] == "covered_by_migros"),
  1097|        "unclear": count(lambda r: r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
  1098|        "possible_duplicates": count(lambda r: r["status"] in {"duplicate", "duplicate_candidate"} or bool(r["duplicate_of_transaction_id"])),
  1099|    }
  1100|
  1101|
  1102|BLOCKED_BULK_EDIT_STATUSES = {"confirmed", "covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference"}
  1103|BLOCKED_BULK_CONFIRM_STATUSES = BLOCKED_BULK_EDIT_STATUSES | {"ignored", "duplicate", "duplicate_candidate", "transfer_candidate"}
  1104|
  1105|
  1106|def _batch_confirm_block_reason(row: Any, *, budget_year: str) -> str | None:
  1107|    status = str(row["status"] or "")
  1108|    classification = str(row["classification"] or "")
  1109|    if status in {"duplicate", "duplicate_candidate"}:
  1110|        return "duplicate_candidate_blocked"
  1111|    if status in {"covered_by_source", "covered_by_migros"}:
  1112|        return status
  1113|    if status in {"auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference", "confirmed", "ignored"}:
  1114|        return f"not_confirmable:{status}"
  1115|    if status == "transfer_candidate" or "transfer" in classification:
  1116|        return "transfer_candidate_blocked"
  1117|    if str(row["transaction_date"] or "")[:4] != str(budget_year):
  1118|        return "outside_active_budget_year"
  1119|    if not row["proposed_category_id"] and classification not in {"income_candidate", "possible_income"}:
  1120|        return "missing_category"
  1121|    if not row["transaction_date"]:
  1122|        return "missing_transaction_date"
  1123|    if not row["amount_original"]:
  1124|        return "missing_amount"
  1125|    if not row["currency_original"]:
  1126|        return "missing_currency"
  1127|    return None
  1128|
  1129|
  1130|def _blocker_summary(conflicts: list[dict[str, Any]]) -> dict[str, int]:
  1131|    def count(needle: str) -> int:
  1132|        return sum(1 for c in conflicts if needle in str(c.get("reason") or ""))
  1133|    return {
  1134|        "duplicate_candidates": count("duplicate"),
  1135|        "covered_by_source": count("covered_by_source") + count("covered_by_migros"),
  1136|        "transfer_candidates": count("transfer"),
  1137|        "without_category": count("missing_category"),
  1138|        "missing_required_fields": count("missing_transaction_date") + count("missing_amount") + count("missing_currency"),
  1139|        "not_confirmable": count("not_confirmable"),
  1140|    }
  1141|
  1142|
  1143|def preview_batch_confirm_candidates(conn: Connection, *, candidate_ids: list[str], account_id: str, budget_year: str = "2026") -> dict[str, Any]:
  1144|    rows = []
  1145|    conflicts: list[dict[str, Any]] = []
  1146|    categories: dict[str, int] = {}
  1147|    sources: dict[str, int] = {}
  1148|    possible_duplicates = 0
  1149|    for cid in candidate_ids:
  1150|

Use offset=1150 to continue reading (showing 740-1149 of 1545 lines)