   700|    """Classify a bank row without confirming it.
   701|
   702|    Positive salary/employer patterns win over generic bank names, while explicit
   703|    own-account / card / investment transfer patterns must never become income.
   704|    """
   705|    if _matches_any(description, ["TRUE WEALTH", "TRUEWEALTH"]):
   706|        return {"status": "transfer_candidate", "requires_review": True, "classification": "investment_transfer", "review_reason": "true_wealth_investment_transfer", "transaction_type": "transfer", "confidence": "0.95"}
   707|    if _matches_any(description, ["VISA", "KREDITKARTE", "KARTENABRECHNUNG", "CREDIT CARD"]):
   708|        return {"status": "transfer_candidate", "requires_review": True, "classification": "credit_card_payment", "review_reason": "credit_card_payment_avoids_double_counting", "transaction_type": "transfer", "confidence": "0.95"}
   709|    if matched_transfer_pair or _matches_any(description, TRANSFER_PATTERNS):
   710|        return {"status": "transfer_candidate", "requires_review": True, "classification": "transfer_candidate", "review_reason": "possible_internal_transfer", "transaction_type": "transfer", "confidence": "0.88"}
   711|    if is_positive and _matches_any(description, STRONG_INCOME_PATTERNS):
   712|        return {"status": "pending", "requires_review": False, "classification": "income_candidate", "review_reason": "strong_income_pattern", "transaction_type": "income", "confidence": "0.90"}
   713|    if is_positive and _matches_any(description, POSSIBLE_INCOME_PATTERNS) and not _matches_any(description, NEGATIVE_INCOME_PATTERNS):
   714|        return {"status": "needs_review", "requires_review": True, "classification": "possible_income", "review_reason": "possible_income_pattern", "transaction_type": "income", "confidence": "0.65"}
   715|    return {"status": "needs_review", "requires_review": True, "classification": "bank_review", "review_reason": "bank_payment_unclear", "transaction_type": "expense" if not is_positive else "income", "confidence": "0.50"}
   716|
   717|
   718|def seed_bank_transfer_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, income_category_id: str | None = None, candidate_source_type: str = "bank_csv") -> dict[str, int]:
   719|    parsed = _bank_rows(rows)
   720|    transfer_indices = _find_transfer_indices(parsed)
   721|    income_name = _category_name(conn, income_category_id)
   722|    created = income = transfer = review = dup_count = investment_transfer = credit_card_payment = 0
   723|    for br in parsed:
   724|        dup = _duplicate_of(conn, br.date, br.amount_abs, br.desc)
   725|        category_id = None; category_name = None
   726|        decision = _bank_candidate_decision(br.desc, is_positive=br.amount_signed > 0, matched_transfer_pair=br.idx in transfer_indices)
   727|        status = str(decision["status"]); needs_review = bool(decision["requires_review"]); classification = str(decision["classification"]); reason = str(decision["review_reason"]); tx_type = str(decision["transaction_type"]); conf = str(decision["confidence"])
   728|        if classification == "income_candidate":
   729|            category_id = income_category_id; category_name = income_name; income += 1
   730|        elif classification == "possible_income":
   731|            category_id = income_category_id; category_name = income_name; review += 1
   732|        elif classification in {"transfer_candidate", "investment_transfer", "credit_card_payment"}:
   733|            transfer += 1
   734|            investment_transfer += 1 if classification == "investment_transfer" else 0
   735|            credit_card_payment += 1 if classification == "credit_card_payment" else 0
   736|        elif dup:
   737|            status = "duplicate_candidate"; classification = "possible_duplicate"; reason = "matched_existing_transaction"; dup_count += 1
   738|        else:
   739|            review += 1
   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','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','duplicate','duplicate_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|            "transfers": "classification='transfer_candidate' OR status='transfer_candidate'",
   778|            "bank": "source_type IN ('bank_csv','raiffeisen_bank','akb_bank')",
   779|            "visa": "source_type='visa_credit_card' OR source_type='credit_card_csv'",
   780|            "migros": "source_type IN ('migros_receipts','migros_receipt') OR status='covered_by_migros'",
   781|            "raiffeisen": "source_type='raiffeisen_bank'",
   782|            "akb": "source_type='akb_bank'",
   783|            "income": "classification='income_candidate'",
   784|            "possible_income": "classification='possible_income'",
   785|            "money_in": "classification IN ('income_candidate','possible_income')",
   786|            "not_income_transfer": "classification IN ('transfer_candidate','investment_transfer','credit_card_payment') OR status='transfer_candidate'",
   787|            "credit_card_payment": "classification='credit_card_payment'",
   788|            "investment_transfers": "classification='investment_transfer'",
   789|            "ignored": "status IN ('ignored','covered_by_migros','covered_by_source','superseded','reference_2025','archived_reference')",
   790|        }
   791|        if tab in mapping:
   792|            clauses.append(mapping[tab])
   793|    if source_type:
   794|        clauses.append("source_type=?"); params.append(source_type)
   795|    if category_id:
   796|        clauses.append("proposed_category_id=?"); params.append(category_id)
   797|    if merchant:
   798|        clauses.append("lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"); params.append(f"%{merchant.lower()}%")
   799|    if rule:
   800|        clauses.append("lower(COALESCE(rule_id,'') || ' ' || COALESCE(rule_name,'') || ' ' || COALESCE(review_reason,'')) LIKE ?"); params.append(f"%{rule.lower()}%")
   801|    if min_confidence:
   802|        clauses.append("CAST(COALESCE(confidence,'0') AS REAL) >= CAST(? AS REAL)"); params.append(min_confidence)
   803|    if date_from:
   804|        clauses.append("transaction_date>=?"); params.append(date_from)
   805|    if date_to:
   806|        clauses.append("transaction_date<=?"); params.append(date_to)
   807|    if amount_min:
   808|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) >= CAST(? AS REAL)"); params.append(amount_min)
   809|    if amount_max:
   810|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) <= CAST(? AS REAL)"); params.append(amount_max)
   811|    where = "WHERE " + " AND ".join(clauses) if clauses else ""
   812|    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"}
   813|    order_col = sort_map.get(sort_by, "created_at")
   814|    direction = "ASC" if sort_dir.lower() == "asc" else "DESC"
   815|    rows = conn.execute(f"SELECT * FROM budget_transaction_candidates {where} ORDER BY {order_col} {direction}, created_at DESC LIMIT 500", params).fetchall()
   816|    return [_candidate_view(r) for r in rows]
   817|
   818|
   819|def reclassify_bank_income_candidates(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
   820|    """Re-score existing AKB/Raiffeisen candidates without confirming bookings."""
   821|    cid_income, cname_income = _category_by_name(conn, ["Lohn Marcel", "Einnahmen", "Sonstige Einnahmen", "Rückerstattungen"])
   822|    rows = conn.execute(
   823|        """
   824|        SELECT * FROM budget_transaction_candidates
   825|        WHERE source_type IN ('akb_bank','raiffeisen_bank','bank_csv')
   826|          AND substr(COALESCE(transaction_date,''),1,4)=?
   827|          AND status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','superseded','reference_2025','archived_reference')
   828|        """,
   829|        (budget_year,),
   830|    ).fetchall()
   831|    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}
   832|    ts = now()
   833|    for row in rows:
   834|        meta = {}
   835|        try:
   836|            meta = json.loads(row["notes"] or "{}") if str(row["notes"] or "").startswith("{") else {}
   837|        except json.JSONDecodeError:
   838|            meta = {}
   839|        is_positive = str(meta.get("transaction_type") or "").lower() == "income"
   840|        decision = _bank_candidate_decision(str(row["description"] or ""), is_positive=is_positive)
   841|        classification = str(decision["classification"])
   842|        if is_positive and classification == "bank_review":
   843|            counts["unclear_money_in_count"] += 1
   844|        if classification == "income_candidate": counts["income_candidate_count"] += 1
   845|        if classification == "possible_income": counts["possible_income_count"] += 1
   846|        if classification in {"transfer_candidate", "investment_transfer", "credit_card_payment"}: counts["transfer_candidate_count"] += 1
   847|        if classification == "investment_transfer": counts["investment_transfer_count"] += 1
   848|        if classification == "credit_card_payment": counts["credit_card_payment_count"] += 1
   849|        proposed_category_id = cid_income if classification in {"income_candidate", "possible_income"} else row["proposed_category_id"]
   850|        proposed_category_name = cname_income if classification in {"income_candidate", "possible_income"} else row["proposed_category_name"]
   851|        notes = json.dumps({"transaction_type": decision["transaction_type"], "reason": decision["review_reason"], "reclassified_by": "income_csv_recognition_v1"}, ensure_ascii=False)
   852|        before = (row["status"], row["classification"], row["review_reason"], row["proposed_category_id"])
   853|        after = (decision["status"], classification, decision["review_reason"], proposed_category_id)
   854|        if before != after:
   855|            conn.execute(
   856|                """
   857|                UPDATE budget_transaction_candidates
   858|                SET status=?, classification=?, requires_review=?, review_reason=?, confidence=?, notes=?, proposed_category_id=?, proposed_category_name=?, updated_at=?
   859|                WHERE transaction_candidate_id=?
   860|                """,
   861|                (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"]),
   862|            )
   863|            counts["updated_count"] += 1
   864|    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")
   865|    conn.commit()
   866|    counts["audit_written"] = 1 if audit_id else 0
   867|    return counts
   868|
   869|
   870|def preview_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
   871|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   872|    if not row:
   873|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   874|    payload = payload or {}
   875|    cat_id = payload.get("category_id") or row["proposed_category_id"]
   876|    notes = row["notes"] or ""
   877|    tx_type = payload.get("transaction_type") or "expense"
   878|    try:
   879|        meta = json.loads(notes) if notes and notes.startswith("{") else {}
   880|        tx_type = payload.get("transaction_type") or meta.get("transaction_type") or tx_type
   881|    except json.JSONDecodeError:
   882|        pass
   883|    if not cat_id and tx_type in {"expense", "income"}:
   884|        raise HTTPException(status_code=422, detail="category required before confirm")
   885|    if cat_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat_id,)).fetchone():
   886|        raise HTTPException(status_code=422, detail="active category required before confirm")
   887|    if not payload.get("account_id"):
   888|        raise HTTPException(status_code=422, detail="account_id required before confirm")
   889|    if not row["transaction_date"]:
   890|        raise HTTPException(status_code=422, detail="transaction date required before confirm")
   891|    if not row["amount_original"]:
   892|        raise HTTPException(status_code=422, detail="amount required before confirm")
   893|    if not row["currency_original"]:
   894|        raise HTTPException(status_code=422, detail="currency required before confirm")
   895|    return {
   896|        "preview_id": new_id("preview"),
   897|        "summary": "Buchungskandidat übernehmen",
   898|        "warnings": ["Möglicher Duplikat-Treffer"] if row["duplicate_of_transaction_id"] else ([] if cat_id or tx_type == "transfer" else ["Kategorie fehlt"]),
   899|        "payload": {
   900|            "account_id": payload.get("account_id"),
   901|            "transaction_type": tx_type,
   902|            "transaction_date": payload.get("transaction_date") or row["transaction_date"],
   903|            "description": payload.get("description") or row["description"],
   904|            "payee": payload.get("payee") or row["merchant"],
   905|            "amount_original": row["amount_original"],
   906|            "currency_original": row["currency_original"],
   907|            "category_id": cat_id,
   908|            "source_type": "import_candidate",
   909|            "source_candidate_id": candidate_id,
   910|            "notes": f"Confirmed from transaction candidate {candidate_id}",
   911|        },
   912|    }
   913|
   914|
   915|def confirm_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   916|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   917|    if not row:
   918|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   919|    if row["status"] == "confirmed":
   920|        raise HTTPException(status_code=409, detail="candidate already confirmed")
   921|    if row["status"] in {"covered_by_migros", "covered_by_source", "superseded", "ignored", "reference_2025", "archived_reference"}:
   922|        raise HTTPException(status_code=409, detail="candidate is not confirmable")
   923|    if row["status"] in {"duplicate", "duplicate_candidate"} and not payload.get("allow_duplicate"):
   924|        raise HTTPException(status_code=409, detail="duplicate candidate requires explicit allow_duplicate")
   925|    if str(row["transaction_date"] or "")[:4] != str(payload.get("budget_year") or "2026"):
   926|        raise HTTPException(status_code=409, detail="candidate outside active budget year")
   927|    preview = preview_transaction_candidate(conn, candidate_id, payload)
   928|    result = confirm_budget_transaction(conn, preview["payload"])
   929|    ts = now()
   930|    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))
   931|    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")
   932|    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")
   933|    conn.commit()
   934|    return {"status": "confirmed", "entity_id": result["entity_id"], "audit_id": audit_id, "message": "Buchungskandidat übernommen"}
   935|
   936|
   937|def ignore_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   938|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   939|    if not row:
   940|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   941|    ts = now()
   942|    conn.execute("UPDATE budget_transaction_candidates SET status='ignored', notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (note, ts, candidate_id))
   943|    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")
   944|    conn.commit()
   945|    return {"status": "ignored", "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat ignoriert"}
   946|
   947|
   948|def reopen_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   949|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   950|    if not row:
   951|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   952|    if row["status"] == "confirmed":
   953|        raise HTTPException(status_code=409, detail="confirmed candidates cannot be reopened")
   954|    new_status = "needs_review" if row["requires_review"] else "pending"
   955|    ts = now()
   956|    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))
   957|    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")
   958|    conn.commit()
   959|    return {"status": new_status, "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat wieder geöffnet"}
   960|
   961|
   962|def update_transaction_candidate_category(conn: Connection, candidate_id: str, category_id: str, note: str | None = None) -> dict[str, Any]:
   963|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   964|    if not row:
   965|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   966|    if not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone():
   967|        raise HTTPException(status_code=422, detail="active category not found")
   968|    cname = _category_name(conn, category_id)
   969|    ts = now()
   970|    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))
   971|    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")
   972|    conn.commit()
   973|    return {"status": "updated", "entity_id": candidate_id, "audit_id": audit_id}
   974|
   975|
   976|def create_candidate_split(conn: Connection, candidate_id: str, splits: list[dict[str, Any]]) -> dict[str, Any]:
   977|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   978|    if not row:
   979|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   980|    if not splits:
   981|        raise HTTPException(status_code=422, detail="splits required")
   982|    ts = now()
   983|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
   984|    for split in splits:
   985|        amount = _decimal_text(split.get("amount_original"))
   986|        if not amount:
   987|            raise HTTPException(status_code=422, detail="split amount required")
   988|        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))
   989|    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))
   990|    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")
   991|    conn.commit()
   992|    return {"status": "split_created", "entity_id": candidate_id, "split_count": len(splits), "audit_id": audit_id}
   993|
   994|
   995|def mark_candidate_type(conn: Connection, candidate_id: str, candidate_type: str, note: str | None = None) -> dict[str, Any]:
   996|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   997|    if not row:
   998|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   999|    allowed = {
  1000|        "transfer": ("transfer_candidate", "transfer_candidate", "user_marked_transfer", "mark_transfer"),
  1001|        "investment_transfer": ("transfer_candidate", "investment_transfer", "user_marked_investment_transfer", "mark_investment_transfer"),
  1002|        "credit_card_payment": ("transfer_candidate", "credit_card_payment", "user_marked_credit_card_payment", "mark_credit_card_payment"),
  1003|    }
  1004|    if candidate_type not in allowed:
  1005|        raise HTTPException(status_code=422, detail="unsupported candidate type")
  1006|    status, classification, reason, action = allowed[candidate_type]
  1007|    ts = now()
  1008|    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))
  1009|    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")
  1010|    conn.commit()
  1011|    return {"status": status, "classification": classification, "entity_id": candidate_id, "audit_id": audit_id}
  1012|
  1013|
  1014|def mark_candidate_transfer(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
  1015|    return mark_candidate_type(conn, candidate_id, "transfer", note)
  1016|
  1017|
  1018|def bulk_update_transaction_candidate_category(conn: Connection, *, candidate_ids: list[str], category_id: str, note: str | None = None) -> dict[str, int]:
  1019|    updated = skipped = 0
  1020|    for cid in candidate_ids:
  1021|        row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1022|        if not row or row["status"] == "confirmed":
  1023|            skipped += 1
  1024|            continue
  1025|        update_transaction_candidate_category(conn, cid, category_id, note or "Bulk category assignment")
  1026|        updated += 1
  1027|    return {"updated_count": updated, "skipped_count": skipped}
  1028|
  1029|
  1030|def _safe_auto_candidate_where() -> str:
  1031|    return """
  1032|        status IN ('pending','auto_categorized')
  1033|        AND COALESCE(requires_review, 0)=0
  1034|        AND duplicate_of_transaction_id IS NULL
  1035|        AND COALESCE(covered_by_source, '')=''
  1036|        AND (
  1037|            classification='subscription_auto'
  1038|            OR classification IN ('migros_auto_under_threshold','migros_receipt_food_household')
  1039|            OR classification='auto_transport'
  1040|        )
  1041|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%galaxus%'
  1042|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%digitec%'
  1043|    """
  1044|
  1045|
  1046|def preview_safe_auto_candidates(conn: Connection) -> dict[str, Any]:
  1047|    rows = conn.execute(
  1048|        f"""
  1049|        SELECT classification, COUNT(*) AS count
  1050|        FROM budget_transaction_candidates
  1051|        WHERE {_safe_auto_candidate_where()}
  1052|        GROUP BY classification
  1053|        ORDER BY classification
  1054|        """
  1055|    ).fetchall()
  1056|    total = sum(int(r["count"] or 0) for r in rows)
  1057|    return {
  1058|        "preview_id": new_id("preview"),
  1059|        "summary": f"{total} sichere Auto-Kandidaten bereit für Confirm",
  1060|        "count": total,
  1061|        "groups": [row_to_dict(r) for r in rows],
  1062|        "warnings": ["Preview only: Confirm benötigt Zielkonto und bucht nur sichere Kandidaten."],
  1063|    }
  1064|
  1065|
  1066|def batch_confirm_safe_candidates(conn: Connection, *, account_id: str, candidate_ids: list[str] | None = None) -> dict[str, int]:
  1067|    if not account_id:
  1068|        raise HTTPException(status_code=422, detail="account_id required")
  1069|    allowed_rows = conn.execute(f"SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE {_safe_auto_candidate_where()}").fetchall()
  1070|    allowed_ids = {str(r["transaction_candidate_id"]) for r in allowed_rows}
  1071|    requested_ids = set(candidate_ids or allowed_ids)
  1072|    confirmed = skipped = 0
  1073|    for cid in sorted(requested_ids):
  1074|        if cid not in allowed_ids:
  1075|            skipped += 1
  1076|            continue
  1077|        confirm_transaction_candidate(conn, cid, {"account_id": account_id})
  1078|        confirmed += 1
  1079|    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")
  1080|    conn.commit()
  1081|    return {"confirmed_count": confirmed, "skipped_count": skipped}
  1082|
  1083|
  1084|def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
  1085|    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()
  1086|    def count(pred):
  1087|        return sum(1 for r in rows if pred(r))
  1088|    return {
  1089|        "open_total": count(lambda r: r["status"] != "covered_by_migros"),
  1090|        "auto_categorized": count(lambda r: r["status"] in {"pending", "auto_categorized"} and not bool(r["requires_review"])),
  1091|        "review_needed": count(lambda r: bool(r["requires_review"])),
  1092|        "galaxus_digitec": count(lambda r: r["classification"] == "galaxus_review"),
  1093|        "migros_over_50": count(lambda r: r["classification"] == "migros_review_over_threshold"),
  1094|        "covered_by_migros": count(lambda r: r["status"] == "covered_by_migros"),
  1095|        "unclear": count(lambda r: r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
  1096|        "possible_duplicates": count(lambda r: r["status"] in {"duplicate", "duplicate_candidate"} or bool(r["duplicate_of_transaction_id"])),
  1097|    }
  1098|
  1099|
  1100|def preview_batch_confirm_candidates(conn: Connection, *, candidate_ids: list[str], account_id: str, budget_year: str = "2026") -> dict[str, Any]:
  1101|    rows = []
  1102|    conflicts = []
  1103|    categories: dict[str, int] = {}
  1104|    for cid in candidate_ids:
  1105|        row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1106|        if not row:
  1107|            conflicts.append({"candidate_id": cid, "reason": "missing"}); continue
  1108|        if row["status"] in {"confirmed", "ignored", "covered_by_migros", "covered_by_source", "superseded", "reference_2025", "archived_reference"}:
  1109|            conflicts.append({"candidate_id": cid, "reason": f"not_confirmable:{row['status']}"}); continue
  1110|        if str(row["transaction_date"] or "")[:4] != str(budget_year):
  1111|            conflicts.append({"candidate_id": cid, "reason": "outside_active_budget_year"}); continue
  1112|        try:
  1113|            preview_transaction_candidate(conn, cid, {"account_id": account_id, "category_id": row["proposed_category_id"], "budget_year": budget_year})
  1114|        except Exception as exc:
  1115|            conflicts.append({"candidate_id": cid, "reason": str(exc)}); continue
  1116|        rows.append(row)
  1117|        categories[str(row["proposed_category_name"] or "Review nötig")] = categories.get(str(row["proposed_category_name"] or "Review nötig"), 0) + 1
  1118|    if not rows and conflicts:
  1119|        raise HTTPException(status_code=409, detail={"message": "no confirmable candidates", "conflicts": conflicts})
  1120|    preview_id = new_id("batchprev")
  1121|    record_audit_event(conn, source="vue_dashboard", action="batch_confirm_candidates_preview", entity_type="budget_transaction_candidate", entity_id=preview_id, new_values={"count": len(rows), "conflicts": len(conflicts), "categories": categories}, created_by="user")
  1122|    conn.commit()
  1123|    return {"preview_id": preview_id, "count": len(rows), "categories": categories, "conflicts": conflicts, "requires_explicit_confirm": True}
  1124|
  1125|
  1126|def batch_mark_candidates(conn: Connection, *, candidate_ids: list[str], action: str, account_id: str | None = None, category_id: str | None = None, preview_id: str | None = None, note: str | None = None, budget_year: str = "2026") -> dict[str, Any]:
  1127|    counts: dict[str, Any] = {"updated_count": 0, "confirmed_count": 0, "ignored_count": 0, "reopened_count": 0, "transfer_count": 0, "covered_by_migros_count": 0, "skipped_count": 0, "errors": []}
  1128|    if action == "confirm" and not preview_id:
  1129|        raise HTTPException(status_code=422, detail="batch confirm requires preview_id")
  1130|    for cid in candidate_ids:
  1131|        try:
  1132|            row_for_year = conn.execute("SELECT transaction_date FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1133|            if not row_for_year:
  1134|                raise HTTPException(status_code=404, detail="candidate not found")
  1135|            if str(row_for_year["transaction_date"] or "")[:4] != str(budget_year):
  1136|                raise HTTPException(status_code=409, detail="candidate outside active budget year")
  1137|            if action == "category":
  1138|                if not category_id: raise HTTPException(status_code=422, detail="category_id required")
  1139|                update_transaction_candidate_category(conn, cid, category_id, note or "Batch category assignment"); counts["updated_count"] += 1
  1140|            elif action == "confirm":
  1141|                confirm_transaction_candidate(conn, cid, {"account_id": account_id, "budget_year": budget_year}); counts["confirmed_count"] += 1
  1142|            elif action == "ignore":
  1143|                ignore_transaction_candidate(conn, cid, note or "Batch ignore"); counts["ignored_count"] += 1
  1144|            elif action == "reopen":
  1145|                reopen_transaction_candidate(conn, cid, note or "Batch reopen"); counts["reopened_count"] += 1
  1146|            elif action == "transfer":
  1147|                mark_candidate_transfer(conn, cid, note or "Batch transfer"); counts["transfer_count"] += 1
  1148|            elif action == "covered_by_migros":
  1149|                ts = now(); row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1150|                if not row: raise HTTPException(status_code=404, detail="candidate not found")
  1151|                conn.execute("UPDATE budget_transaction_candidates SET status='covered_by_migros', classification='covered_by_migros', covered_by_source='migros_csv', requires_review=0, review_reason='user_batch_marked_covered_by_migros', updated_at=? WHERE transaction_candidate_id=?", (ts, cid))
  1152|                record_audit_event(conn, source="vue_dashboard", action="mark_covered_by_migros", entity_type="budget_transaction_candidate", entity_id=cid, old_values={"status": row["status"]}, new_values={"status": "covered_by_migros"}, created_by="user")
  1153|                counts["covered_by_migros_count"] += 1
  1154|            else:
  1155|                raise HTTPException(status_code=422, detail="invalid batch action")
  1156|        except Exception as exc:
  1157|            counts["skipped_count"] += 1
  1158|            detail = getattr(exc, "detail", str(exc))
  1159|            counts["errors"].append({"candidate_id": cid, "error": str(detail)})
  1160|    audit_id = record_audit_event(conn, source="vue_dashboard", action="batch_confirm_candidates" if action == "confirm" else f"batch_{action}_candidates", entity_type="budget_transaction_candidate", entity_id=preview_id or "batch", new_values=counts | {"candidate_count": len(candidate_ids)}, created_by="user")
  1161|    conn.commit()
  1162|    return counts | {"audit_id": audit_id}
  1163|
  1164|
  1165|def confirm_candidate_split(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
  1166|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
  1167|    if not row:
  1168|        raise HTTPException(status_code=404, detail="transaction candidate not found")
  1169|    if row["status"] in {"confirmed", "ignored", "covered_by_migros", "superseded"}:
  1170|        raise HTTPException(status_code=409, detail="candidate is not splittable")
  1171|    splits = payload.get("splits") or []
  1172|    if not payload.get("account_id"):
  1173|        raise HTTPException(status_code=422, detail="account_id required")
  1174|    if not splits:
  1175|        raise HTTPException(status_code=422, detail="splits required")
  1176|    total = sum(Decimal(_decimal_text(s.get("amount_original")) or "0") for s in splits)
  1177|    candidate_amount = Decimal(str(row["amount_original"] or "0"))
  1178|    if total != candidate_amount:
  1179|        raise HTTPException(status_code=422, detail="split sum must match candidate amount")
  1180|    group_id = new_id("splitgrp")
  1181|    ts = now()
  1182|    created_ids = []
  1183|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
  1184|    for idx, split in enumerate(splits, start=1):
  1185|        amount = _decimal_text(split.get("amount_original"))
  1186|        cat = split.get("category_id")
  1187|        if not cat or not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat,)).fetchone():
  1188|            raise HTTPException(status_code=422, detail="active split category required")
  1189|        result = confirm_budget_transaction(conn, {
  1190|            "account_id": payload["account_id"], "transaction_type": payload.get("transaction_type") or "expense", "transaction_date": row["transaction_date"],
  1191|            "description": f"{row['description']} · Split {idx}", "amount_original": amount, "currency_original": row["currency_original"],
  1192|            "category_id": cat, "source_type": "import_candidate", "source_candidate_id": candidate_id,
  1193|            "notes": f"Split group {group_id}: {split.get('note') or ''}", "tag_names": [split.get("tag")] if split.get("tag") else [],
  1194|        })
  1195|        created_ids.append(result["entity_id"])
  1196|        conn.execute("INSERT INTO budget_candidate_splits(split_id, transaction_candidate_id, category_id, amount_original, notes, tag_name, confirmed_transaction_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (new_id("bcsplit"), candidate_id, cat, amount, split.get("note"), split.get("tag"), result["entity_id"], ts, ts))
  1197|    primary_confirmed_id = created_ids[0] if created_ids else group_id
  1198|    conn.execute("UPDATE budget_transaction_candidates SET status='confirmed', confirmed_transaction_id=?, confirmed_at=?, confirmed_by='user', updated_at=? WHERE transaction_candidate_id=?", (primary_confirmed_id, ts, ts, candidate_id))
  1199|    audit_id = record_audit_event(conn, source="vue_dashboard", action="candidate_split_confirmed", entity_type="budget_transaction_candidate", entity_id=candidate_id, new_values={"group_id": group_id, "primary_transaction_id": primary_confirmed_id, "transaction_ids": created_ids, "split_count": len(created_ids)}, created_by="user")
  1200|    conn.commit()
  1201|    return {"status": "confirmed", "group_id": primary_confirmed_id, "split_group_id": group_id, "created_transactions": len(created_ids), "transaction_ids": created_ids, "audit_id": audit_id}
  1202|
  1203|
  1204|def create_review_category_for_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
  1205|    if not conn.execute("SELECT 1 FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone():
  1206|        raise HTTPException(status_code=404, detail="transaction candidate not found")
  1207|    name = str(payload.get("name") or "").strip()
  1208|    if not name:
  1209|        raise HTTPException(status_code=422, detail="category name required")
  1210|    ts = now(); category_id = new_id("bcat")
  1211|    conn.execute("INSERT INTO budget_categories(category_id, parent_category_id, name, category_type, color, icon, is_active, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, NULL, NULL, 1, 999, ?, ?)", (category_id, payload.get("parent_category_id") or None, name, payload.get("category_type") or "expense", ts, ts))
  1212|    update_transaction_candidate_category(conn, candidate_id, category_id, "Created from review")
  1213|    audit_id = record_audit_event(conn, source="vue_dashboard", action="review_category_created", entity_type="budget_category", entity_id=category_id, new_values={"name": name, "candidate_id": candidate_id}, created_by="user")
  1214|    conn.commit()
  1215|    return {"status": "created", "category_id": category_id, "audit_id": audit_id}
  1216|
  1217|
  1218|def create_budget_rule(conn: Connection, payload: dict[str, Any]) -> dict[str, Any]:
  1219|    text = str(payload.get("merchant_contains") or "").strip()
  1220|    if not text:
  1221|        raise HTTPException(status_code=422, detail="merchant_contains required")
  1222|    target_status = payload.get("target_status") or "review"
  1223|    if target_status not in {"auto", "review"}:
  1224|        raise HTTPException(status_code=422, detail="invalid target_status")
  1225|    priority = int(payload.get("priority") or 100)
  1226|    confidence = str(payload.get("confidence") or "0.82")
  1227|    rule_id = new_id("brule"); ts = now()
  1228|    conn.execute("INSERT INTO budget_review_rules(rule_id, merchant_contains, source_type, category_id, target_status, is_active, created_at, updated_at, priority, confidence, notes) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", (rule_id, text, payload.get("source_type") or None, payload.get("category_id") or None, target_status, ts, ts, priority, confidence, payload.get("notes")))
  1229|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_created", entity_type="budget_review_rule", entity_id=rule_id, new_values={"merchant_contains": text, "target_status": target_status, "priority": priority, "confidence": confidence}, created_by="user")
  1230|    conn.commit()
  1231|    return {"status": "created", "rule_id": rule_id, "audit_id": audit_id}
  1232|
  1233|
  1234|def list_budget_rules(conn: Connection, *, include_inactive: bool = False) -> list[dict[str, Any]]:
  1235|    where = "" if include_inactive else "WHERE r.is_active=1"
  1236|    rows = conn.execute(f"SELECT r.*, c.name AS category_name FROM budget_review_rules r LEFT JOIN budget_categories c ON c.category_id=r.category_id {where} ORDER BY r.priority ASC, r.created_at DESC").fetchall()
  1237|    return [row_to_dict(r) | {"is_active": bool(r["is_active"])} for r in rows]
  1238|
  1239|
  1240|def _rule_match_rows(conn: Connection, rule_id: str):
  1241|    rule = conn.execute("SELECT * FROM budget_review_rules WHERE rule_id=?", (rule_id,)).fetchone()
  1242|    if not rule:
  1243|        raise HTTPException(status_code=404, detail="rule not found")
  1244|    clauses = ["status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','superseded','reference_2025','archived_reference')", "lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"]
  1245|    params: list[Any] = [f"%{str(rule['merchant_contains']).lower()}%"]
  1246|    if rule["source_type"]:
  1247|        clauses.append("source_type=?"); params.append(rule["source_type"])
  1248|    return rule, conn.execute("SELECT * FROM budget_transaction_candidates WHERE " + " AND ".join(clauses), params).fetchall()
  1249|
  1250|
  1251|def test_budget_rule(conn: Connection, rule_id: str) -> dict[str, Any]:
  1252|    rule, rows = _rule_match_rows(conn, rule_id)
  1253|    return {"rule_id": rule_id, "match_count": len(rows), "would_set_category": rule["category_id"], "would_set_status": rule["target_status"], "confidence": rule["confidence"], "candidate_ids": [r["transaction_candidate_id"] for r in rows]}
  1254|
  1255|
  1256|def apply_budget_rules_to_open_candidates(conn: Connection, rule_id: str) -> dict[str, Any]:
  1257|    rule, rows = _rule_match_rows(conn, rule_id)
  1258|    if not rule["is_active"]:
  1259|        raise HTTPException(status_code=409, detail="rule inactive")
  1260|    status = "auto_categorized" if rule["target_status"] == "auto" else "needs_review"
  1261|    requires_review = 0 if rule["target_status"] == "auto" else 1
  1262|    cname = _category_name(conn, rule["category_id"])
  1263|    ts = now(); updated = 0
  1264|    for row in rows:
  1265|        conn.execute("UPDATE budget_transaction_candidates SET proposed_category_id=?, proposed_category_name=?, status=?, requires_review=?, rule_id=?, rule_name=?, review_reason='rule_mvp_suggestion_only', confidence=?, updated_at=? WHERE transaction_candidate_id=?", (rule["category_id"], cname, status, requires_review, rule_id, f"Regel: enthält {rule['merchant_contains']}", str(rule["confidence"] or "0.82"), ts, row["transaction_candidate_id"]))
  1266|        updated += 1
  1267|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_applied_to_candidates", entity_type="budget_review_rule", entity_id=rule_id, new_values={"updated_count": updated, "productive_transaction_count": 0}, created_by="user")
  1268|    conn.commit()
  1269|    return {"updated_count": updated, "audit_id": audit_id, "productive_transaction_count": 0}
  1270|
  1271|
  1272|def update_budget_rule(conn: Connection, rule_id: str, payload: dict[str, Any]) -> dict[str, Any]:
  1273|    row = conn.execute("SELECT * FROM budget_review_rules WHERE rule_id=?", (rule_id,)).fetchone()
  1274|    if not row:
  1275|        raise HTTPException(status_code=404, detail="rule not found")
  1276|    allowed = {"merchant_contains", "source_type", "category_id", "target_status", "priority", "confidence", "notes", "is_active"}
  1277|    updates: dict[str, Any] = {k: payload[k] for k in allowed if k in payload}
  1278|    if "target_status" in updates and updates["target_status"] not in {"auto", "review"}:
  1279|        raise HTTPException(status_code=422, detail="invalid target_status")
  1280|    if "merchant_contains" in updates and not str(updates["merchant_contains"] or "").strip():
  1281|        raise HTTPException(status_code=422, detail="merchant_contains required")
  1282|    if not updates:
  1283|        raise HTTPException(status_code=422, detail="no editable fields supplied")
  1284|    updates["updated_at"] = now()
  1285|    set_clause = ", ".join([f"{k}=?" for k in updates])
  1286|    conn.execute(f"UPDATE budget_review_rules SET {set_clause} WHERE rule_id=?", [updates[k] for k in updates] + [rule_id])
  1287|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_updated", entity_type="budget_review_rule", entity_id=rule_id, old_values=row_to_dict(row), new_values=updates, created_by="user")
  1288|    conn.commit()
  1289|    return {"status": "updated", "rule_id": rule_id, "audit_id": audit_id}
  1290|
  1291|
  1292|def prioritize_budget_rule(conn: Connection, rule_id: str, priority: int) -> dict[str, Any]:
  1293|    return update_budget_rule(conn, rule_id, {"priority": int(priority)}) | {"priority": int(priority)}
  1294|
  1295|
  1296|def deactivate_budget_rule(conn: Connection, rule_id: str) -> dict[str, Any]:
  1297|    row = conn.execute("SELECT is_active FROM budget_review_rules WHERE rule_id=?", (rule_id,)).fetchone()
  1298|    if not row:
  1299|        raise HTTPException(status_code=404, detail="rule not found")
  1300|    ts = now(); conn.execute("UPDATE budget_review_rules SET is_active=0, updated_at=? WHERE rule_id=?", (ts, rule_id))
  1301|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_deactivated", entity_type="budget_review_rule", entity_id=rule_id, old_values={"is_active": row["is_active"]}, new_values={"is_active": 0}, created_by="user")
  1302|    conn.commit(); return {"status": "deactivated", "rule_id": rule_id, "audit_id": audit_id}
  1303|
  1304|
  1305|def create_merchant(conn: Connection, payload: dict[str, Any]) -> dict[str, Any]:
  1306|    display_name = str(payload.get("display_name") or "").strip()
  1307|    if not display_name:
  1308|        raise HTTPException(status_code=422, detail="display_name required")
  1309|    default_category_id = payload.get("default_category_id") or None
  1310|    if default_category_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (default_category_id,)).fetchone():
  1311|        raise HTTPException(status_code=422, detail="active default category required")
  1312|    merchant_id = new_id("bmerch")
  1313|    ts = now()
  1314|    normalized = _normalized_merchant_name(display_name)
  1315|    conn.execute("INSERT INTO budget_merchants(merchant_id, display_name, normalized_name, default_category_id, is_active, notes, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?, ?)", (merchant_id, display_name, normalized, default_category_id, payload.get("notes"), ts, ts))
  1316|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_merchant_created", entity_type="budget_merchant", entity_id=merchant_id, new_values={"display_name": display_name, "default_category_id": default_category_id}, created_by="user")
  1317|    conn.commit()
  1318|    return {"status": "created", "merchant_id": merchant_id, "audit_id": audit_id}
  1319|
  1320|
  1321|def list_merchants(conn: Connection, *, include_inactive: bool = False) -> list[dict[str, Any]]:
  1322|    where = "" if include_inactive else "WHERE m.is_active=1"
  1323|    rows = conn.execute(f"""
  1324|        SELECT m.*, c.name AS default_category_name,
  1325|          (SELECT COUNT(*) FROM budget_merchant_aliases a WHERE a.merchant_id=m.merchant_id AND a.is_active=1) AS alias_count,
  1326|          (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','superseded')) AS open_candidate_count
  1327|        FROM budget_merchants m LEFT JOIN budget_categories c ON c.category_id=m.default_category_id
  1328|        {where} ORDER BY m.is_active DESC, lower(m.display_name)
  1329|    """).fetchall()
  1330|    return [row_to_dict(r) | {"is_active": bool(r["is_active"])} for r in rows]
  1331|
  1332|
  1333|def create_merchant_alias(conn: Connection, merchant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
  1334|    if not conn.execute("SELECT 1 FROM budget_merchants WHERE merchant_id=? AND is_active=1", (merchant_id,)).fetchone():
  1335|        raise HTTPException(status_code=404, detail="merchant not found")
  1336|    pattern = str(payload.get("pattern") or "").strip()
  1337|    match_type = payload.get("match_type") or "contains"
  1338|    if not pattern:
  1339|        raise HTTPException(status_code=422, detail="pattern required")
  1340|    if match_type not in {"contains", "exact", "regex"}:
  1341|        raise HTTPException(status_code=422, detail="invalid match_type")
  1342|    alias_id = new_id("bmalias")
  1343|    ts = now()
  1344|    conn.execute("INSERT INTO budget_merchant_aliases(alias_id, merchant_id, pattern, match_type, source_type, priority, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)", (alias_id, merchant_id, pattern, match_type, payload.get("source_type") or None, int(payload.get("priority") or 100), ts, ts))
  1345|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_merchant_alias_created", entity_type="budget_merchant_alias", entity_id=alias_id, new_values={"merchant_id": merchant_id, "pattern": pattern, "match_type": match_type}, created_by="user")
  1346|    conn.commit()
  1347|    return {"status": "created", "alias_id": alias_id, "audit_id": audit_id}
  1348|
  1349|
  1350|def update_merchant_category(conn: Connection, merchant_id: str, category_id: str | None) -> dict[str, Any]:
  1351|    row = conn.execute("SELECT * FROM budget_merchants WHERE merchant_id=?", (merchant_id,)).fetchone()
  1352|    if not row:
  1353|        raise HTTPException(status_code=404, detail="merchant not found")
  1354|    if category_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone():
  1355|        raise HTTPException(status_code=422, detail="active category required")
  1356|    ts = now()
  1357|    conn.execute("UPDATE budget_merchants SET default_category_id=?, updated_at=? WHERE merchant_id=?", (category_id, ts, merchant_id))
  1358|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_merchant_category_updated", entity_type="budget_merchant", entity_id=merchant_id, old_values={"default_category_id": row["default_category_id"]}, new_values={"default_category_id": category_id}, created_by="user")
  1359|    conn.commit()
  1360|    return {"status": "updated", "merchant_id": merchant_id, "audit_id": audit_id}
  1361|
  1362|
  1363|def _open_candidates_for_merchants(conn: Connection):
  1364|    return conn.execute("SELECT * FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','superseded','reference_2025','archived_reference')").fetchall()
  1365|
  1366|
  1367|def apply_merchant_to_open_candidates(conn: Connection, merchant_id: str | None = None) -> dict[str, Any]:
  1368|    merchant_clause = "AND m.merchant_id=?" if merchant_id else ""
  1369|    params: list[Any] = [merchant_id] if merchant_id else []
  1370|    aliases = conn.execute(f"""
  1371|        SELECT a.*, m.display_name, m.default_category_id, c.name AS default_category_name
  1372|        FROM budget_merchant_aliases a
  1373|        JOIN budget_merchants m ON m.merchant_id=a.merchant_id AND m.is_active=1
  1374|        LEFT JOIN budget_categories c ON c.category_id=m.default_category_id
  1375|        WHERE a.is_active=1 {merchant_clause}
  1376|        ORDER BY a.priority ASC, length(a.pattern) DESC
  1377|    """, params).fetchall()
  1378|    updated = 0
  1379|    ts = now()
  1380|    for cand in _open_candidates_for_merchants(conn):
  1381|        for alias in aliases:
  1382|            if not _merchant_alias_matches(alias, cand):
  1383|                continue
  1384|            status = "auto_categorized" if alias["default_category_id"] else "needs_review"
  1385|            requires_review = 0 if alias["default_category_id"] else 1
  1386|            conn.execute("""
  1387|                UPDATE budget_transaction_candidates
  1388|                SET merchant_id=?, merchant_display_name=?, merchant=?, proposed_category_id=COALESCE(?, proposed_category_id), proposed_category_name=COALESCE(?, proposed_category_name),
  1389|                    status=?, status_label=?, requires_review=?, review_reason='merchant_alias_match', confidence='0.90', updated_at=?
  1390|                WHERE transaction_candidate_id=?
  1391|            """, (alias["merchant_id"], alias["display_name"], alias["display_name"], alias["default_category_id"], alias["default_category_name"], status, STATUS_LABELS[status], requires_review, ts, cand["transaction_candidate_id"]))
  1392|            updated += 1
  1393|            break
  1394|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_merchant_applied_to_candidates", entity_type="budget_merchant", entity_id=merchant_id or "all", new_values={"updated_count": updated}, created_by="user")
  1395|    conn.commit()
  1396|    return {"updated_count": updated, "audit_id": audit_id}
  1397|
  1398|
  1399|def merge_merchants(conn: Connection, source_merchant_id: str, target_merchant_id: str) -> dict[str, Any]:
  1400|    if source_merchant_id == target_merchant_id:
  1401|        raise HTTPException(status_code=422, detail="source and target must differ")
  1402|    source = conn.execute("SELECT * FROM budget_merchants WHERE merchant_id=?", (source_merchant_id,)).fetchone()
  1403|    target = conn.execute("SELECT * FROM budget_merchants WHERE merchant_id=?", (target_merchant_id,)).fetchone()
  1404|    if not source or not target:
  1405|        raise HTTPException(status_code=404, detail="merchant not found")
  1406|    ts = now()
  1407|    conn.execute("UPDATE budget_merchant_aliases SET merchant_id=?, updated_at=? WHERE merchant_id=?", (target_merchant_id, ts, source_merchant_id))
  1408|    conn.execute("UPDATE budget_transaction_candidates SET merchant_id=?, merchant_display_name=?, merchant=? WHERE merchant_id=?", (target_merchant_id, target["display_name"], target["display_name"], source_merchant_id))
  1409|    conn.execute("UPDATE budget_merchants SET is_active=0, updated_at=?, notes=COALESCE(notes,'') || ' merged_into:' || ? WHERE merchant_id=?", (ts, target_merchant_id, source_merchant_id))
  1410|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_merchant_merged", entity_type="budget_merchant", entity_id=target_merchant_id, old_values={"source_merchant_id": source_merchant_id}, new_values={"target_merchant_id": target_merchant_id}, created_by="user")
  1411|    conn.commit()
  1412|    return {"status": "merged", "source_merchant_id": source_merchant_id, "target_merchant_id": target_merchant_id, "audit_id": audit_id}
  1413|
  1414|
  1415|def _similar_merchant(a: str, b: str) -> bool:
  1416|    aa = set(_normalized_merchant_name(a).split())
  1417|    bb = set(_normalized_merchant_name(b).split())
  1418|    if not aa or not bb:
  1419|        return False
  1420|    return bool(aa & bb) or _normalized_merchant_name(a) in _normalized_merchant_name(b) or _normalized_merchant_name(b) in _normalized_merchant_name(a)
  1421|
  1422|
  1423|def detect_duplicate_candidates(conn: Connection, *, day_window: int = 2) -> dict[str, Any]:
  1424|    candidates = conn.execute("""
  1425|        SELECT * FROM budget_transaction_candidates
  1426|        WHERE status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','superseded','reference_2025','archived_reference')
  1427|          AND transaction_date IS NOT NULL AND amount_original IS NOT NULL
  1428|    """).fetchall()
  1429|    transactions = conn.execute("SELECT * FROM budget_transactions WHERE status='confirmed' AND transaction_date IS NOT NULL").fetchall()
  1430|    updated = 0
  1431|    ts = now()
  1432|    for cand in candidates:
  1433|        try:
  1434|            cdate = datetime.fromisoformat(str(cand["transaction_date"])[:10])
  1435|            camount = Decimal(str(cand["amount_original"]))
  1436|        except Exception:
  1437|            continue
  1438|        cmerchant = str(cand["merchant_display_name"] or cand["merchant"] or cand["description"] or "")
  1439|        for tx in transactions:
  1440|            try:
  1441|                tdate = datetime.fromisoformat(str(tx["transaction_date"])[:10])
  1442|                tamount = Decimal(str(tx["amount_original"]))
  1443|            except Exception:
  1444|                continue
  1445|            if abs((cdate - tdate).days) > day_window or camount != tamount or str(cand["currency_original"] or "CHF") != str(tx["currency_original"] or "CHF"):
  1446|                continue
  1447|            if not _similar_merchant(cmerchant, str(tx["payee"] or tx["description"] or "")):
  1448|                continue
  1449|            conn.execute("UPDATE budget_transaction_candidates SET status='duplicate_candidate', status_label=?, duplicate_of_transaction_id=?, requires_review=1, review_reason='duplicate_detection_v1', updated_at=? WHERE transaction_candidate_id=?", (STATUS_LABELS["duplicate_candidate"], tx["budget_transaction_id"], ts, cand["transaction_candidate_id"]))
  1450|            updated += 1
  1451|            break
  1452|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_duplicate_detection_v1", entity_type="budget_transaction_candidate", entity_id="duplicate_detection_v1", new_values={"duplicate_count": updated}, created_by="user")
  1453|    conn.commit()
  1454|    return {"duplicate_count": updated, "audit_id": audit_id}
  1455|
  1456|
  1457|def archive_reference_year_candidates(conn: Connection, *, active_year: str = "2026", reference_year: str = "2025") -> dict[str, int | str]:
  1458|    ts = now()
  1459|    rows = conn.execute("SELECT * FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirmed','reference_2025','archived_reference','superseded')", (reference_year,)).fetchall()
  1460|

Use offset=1460 to continue reading (showing 700-1459 of 1484 lines)


Explanation:
**Planning category implementation**

I need to implement some features and create tests for them. I'll inspect migration tables for merchant fields to see if that's enough. In the categories.py file, I must finalize the list while ensuring the usage includes checks for merchants, rules, and active children. I also want to set up functions for previewing updates and deletions, and think about how the user can preview these changes. There will be a need for API endpoints, and I might add drag-and-drop functionality to the frontend for better usability.


   260|
   261|@router.get("/categories")
   262|def categories(include_archived: bool = False, grouped: bool = False, conn: Connection = Depends(get_db)):
   263|    if grouped:
   264|        return list_category_tree_grouped(conn, include_archived=include_archived)
   265|    return list_categories(conn, include_archived=include_archived)
   266|
   267|
   268|@router.get("/categories/tree")
   269|def category_tree(include_archived: bool = False, conn: Connection = Depends(get_db)):
   270|    return list_category_tree_grouped(conn, include_archived=include_archived)
   271|
   272|
   273|@router.get("/tags")
   274|def tags(include_archived: bool = False, conn: Connection = Depends(get_db)):
   275|    return list_tags(conn, include_archived=include_archived)
   276|
   277|
   278|@router.post("/tags/preview")
   279|def tags_preview(payload: dict, conn: Connection = Depends(get_db)):
   280|    return create_tag_preview(conn, payload)
   281|
   282|
   283|@router.post("/tags/confirm")
   284|def tags_confirm(payload: dict, conn: Connection = Depends(get_db)):
   285|    return confirm_create_tag(conn, payload)
   286|
   287|
   288|@router.post("/tags/{tag_id}/archive")
   289|def tags_archive(tag_id: str, conn: Connection = Depends(get_db)):
   290|    return archive_tag(conn, tag_id)
   291|
   292|
   293|@router.get("/plans")
   294|def plans(month: str | None = None, include_archived: bool = True, conn: Connection = Depends(get_db)):
   295|    return list_budget_plan_items(conn, month=month, include_archived=include_archived)
   296|
   297|
   298|@router.post("/plans/preview")
   299|def plans_preview(payload: dict, conn: Connection = Depends(get_db)):
   300|    return create_budget_plan_item_preview(conn, payload)
   301|
   302|
   303|@router.post("/plans/confirm")
   304|def plans_confirm(payload: dict, conn: Connection = Depends(get_db)):
   305|    return confirm_budget_plan_item(conn, payload)
   306|
   307|
   308|@router.get("/seed-candidates")
   309|def seed_candidates(status: str | None = None, candidate_type: str | None = None, conn: Connection = Depends(get_db)):
   310|    return list_seed_candidates(conn, status=status, candidate_type=candidate_type)
   311|
   312|
   313|@router.get("/import-profiles")
   314|def budget_import_profiles():
   315|    return import_profile_specs()
   316|
   317|
   318|@router.post("/import-profiles/{profile_name}/dry-run")
   319|def budget_import_profile_dry_run(profile_name: str, payload: dict, conn: Connection = Depends(get_db)):
   320|    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"))
   321|
   322|
   323|@router.post("/import-profiles/{profile_name}/candidates")
   324|def budget_import_profile_candidates(profile_name: str, payload: dict, conn: Connection = Depends(get_db)):
   325|    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"))
   326|
   327|
   328|@router.get("/transaction-candidates")
   329|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)):
   330|    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)
   331|
   332|
   333|@router.get("/transaction-candidates/summary")
   334|def transaction_candidates_summary(budget_year: str = "2026", conn: Connection = Depends(get_db)):
   335|    return review_candidate_summary(conn, budget_year=budget_year)
   336|
   337|
   338|@router.post("/transaction-candidates/generate")
   339|def transaction_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   340|    source_type = payload.get("source_type") or "csv_seed"
   341|    rows = payload.get("rows") or []
   342|    if source_type == "credit_card_csv":
   343|        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))
   344|    if source_type == "migros_csv":
   345|        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"))
   346|    if source_type == "bank_csv":
   347|        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"))
   348|    if payload.get("csv_text"):
   349|        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)
   350|    return seed_transaction_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Budget Import", source_type=source_type)
   351|
   352|
   353|@router.post("/transaction-candidates/bank-income/reclassify")
   354|def transaction_candidates_bank_income_reclassify(payload: dict | None = None, conn: Connection = Depends(get_db)):
   355|    return reclassify_bank_income_candidates(conn, budget_year=str((payload or {}).get("budget_year") or "2026"))
   356|
   357|
   358|@router.post("/transaction-candidates/migros-2026/preview")
   359|def transaction_candidates_migros_2026_preview(payload: dict | None = None, conn: Connection = Depends(get_db)):
   360|    return preview_migros_receipts_2026(conn, year=str((payload or {}).get("year") or "2026"))
   361|
   362|
   363|@router.post("/transaction-candidates/migros-2026/confirm")
   364|def transaction_candidates_migros_2026_confirm(payload: dict, conn: Connection = Depends(get_db)):
   365|    return confirm_migros_receipts_2026(conn, account_id=str(payload.get("account_id") or ""), year=str(payload.get("year") or "2026"))
   366|
   367|
   368|@router.post("/transaction-candidates/{candidate_id}/preview")
   369|def transaction_candidates_preview(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   370|    return preview_transaction_candidate(conn, candidate_id, payload or {})
   371|
   372|
   373|@router.post("/transaction-candidates/{candidate_id}/confirm")
   374|def transaction_candidates_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   375|    return confirm_transaction_candidate(conn, candidate_id, payload)
   376|
   377|
   378|@router.post("/transaction-candidates/{candidate_id}/ignore")
   379|def transaction_candidates_ignore(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   380|    return ignore_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   381|
   382|
   383|@router.post("/transaction-candidates/{candidate_id}/reopen")
   384|def transaction_candidates_reopen(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   385|    return reopen_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   386|
   387|
   388|@router.post("/transaction-candidates/{candidate_id}/category")
   389|def transaction_candidates_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   390|    return update_transaction_candidate_category(conn, candidate_id, payload.get("category_id"), payload.get("note"))
   391|
   392|
   393|@router.post("/transaction-candidates/bulk-category")
   394|def transaction_candidates_bulk_category(payload: dict, conn: Connection = Depends(get_db)):
   395|    category_id = str(payload.get("category_id") or "")
   396|    return bulk_update_transaction_candidate_category(conn, candidate_ids=payload.get("candidate_ids") or [], category_id=category_id, note=payload.get("note"))
   397|
   398|
   399|@router.post("/transaction-candidates/{candidate_id}/split")
   400|def transaction_candidates_split(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   401|    return create_candidate_split(conn, candidate_id, payload.get("splits") or [])
   402|
   403|
   404|@router.post("/transaction-candidates/{candidate_id}/split/confirm")
   405|def transaction_candidates_split_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   406|    return confirm_candidate_split(conn, candidate_id, payload)
   407|
   408|
   409|@router.post("/transaction-candidates/{candidate_id}/category/create")
   410|def transaction_candidates_create_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   411|    return create_review_category_for_candidate(conn, candidate_id, payload)
   412|
   413|
   414|@router.post("/transaction-candidates/{candidate_id}/transfer")
   415|def transaction_candidates_transfer(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   416|    return mark_candidate_transfer(conn, candidate_id, (payload or {}).get("note"))
   417|
   418|
   419|@router.post("/transaction-candidates/{candidate_id}/type")
   420|def transaction_candidates_type(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   421|    return mark_candidate_type(conn, candidate_id, str(payload.get("candidate_type") or ""), (payload or {}).get("note"))
   422|
   423|
   424|@router.post("/transaction-candidates/batch-confirm-safe/preview")
   425|def transaction_candidates_batch_confirm_safe_preview(conn: Connection = Depends(get_db)):
   426|    return preview_safe_auto_candidates(conn)
   427|
   428|
   429|@router.post("/transaction-candidates/batch/preview")
   430|def transaction_candidates_batch_preview(payload: dict, conn: Connection = Depends(get_db)):
   431|    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"))
   432|
   433|
   434|@router.post("/transaction-candidates/batch")
   435|def transaction_candidates_batch(payload: dict, conn: Connection = Depends(get_db)):
   436|    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"))
   437|
   438|
   439|@router.post("/transaction-candidates/archive-reference-year")
   440|def transaction_candidates_archive_reference(payload: dict | None = None, conn: Connection = Depends(get_db)):
   441|    payload = payload or {}
   442|    return archive_reference_year_candidates(conn, active_year=str(payload.get("active_year") or "2026"), reference_year=str(payload.get("reference_year") or "2025"))
   443|
   444|
   445|@router.post("/transaction-candidates/batch-confirm-safe")
   446|def transaction_candidates_batch_confirm_safe(payload: dict, conn: Connection = Depends(get_db)):
   447|    account_id = str(payload.get("account_id") or "")
   448|    return batch_confirm_safe_candidates(conn, account_id=account_id, candidate_ids=payload.get("candidate_ids") or None)
   449|
   450|
   451|@router.post("/transaction-candidates/detect-duplicates")
   452|def transaction_candidates_detect_duplicates(conn: Connection = Depends(get_db)):
   453|    return detect_duplicate_candidates(conn)
   454|
   455|
   456|@router.get("/merchants")
   457|def merchants(include_inactive: bool = False, conn: Connection = Depends(get_db)):
   458|    return list_merchants(conn, include_inactive=include_inactive)
   459|
   460|
   461|@router.post("/merchants")
   462|def merchants_create(payload: dict, conn: Connection = Depends(get_db)):
   463|    return create_merchant(conn, payload)
   464|
   465|
   466|@router.post("/merchants/{merchant_id}/aliases")
   467|def merchants_create_alias(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   468|    return create_merchant_alias(conn, merchant_id, payload)
   469|
   470|
   471|@router.post("/merchants/{merchant_id}/category")
   472|def merchants_update_category(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   473|    return update_merchant_category(conn, merchant_id, payload.get("category_id"))
   474|
   475|
   476|@router.post("/merchants/{merchant_id}/apply")
   477|def merchants_apply(merchant_id: str, conn: Connection = Depends(get_db)):
   478|    return apply_merchant_to_open_candidates(conn, merchant_id)
   479|
   480|

Use offset=480 to continue reading (showing 260-479 of 653 lines)


Ich schreibe jetzt RED-Tests: finale Expense-Struktur/Sortierung, CRUD-Safety inkl. Merchant/Rule-Referenzen, Reorder, Duplicate-Ausblendung. Danach lasse ich sie gezielt scheitern.

Explanation:
**Planning code patches and tests**

I need to work on code patches and start by running tests. Writing backend tests is definitely key, and I might need to set up SQL. I’ll utilize existing test helpers from before to create new tests. I’ll use direct INSERT commands and keep track of the migration version, moving from 27 to 28. Also, the tests will expect that some functions don’t exist yet, so I’ll need to import future functions, along with the necessary category references.

from __future__ import annotations

import pytest
from fastapi import HTTPException

from jarvis_finance.services.budget_accounts import confirm_create_budget_account
from jarvis_finance.services.budget_categories import (
    FINAL_EXPENSE_CATEGORIES,
    archive_category,
    confirm_budget_category_cleanup,
    confirm_create_category,
    delete_empty_category,
    preview_budget_category_cleanup,
    reorder_categories,
    update_category,
)
from jarvis_finance.services.budget_imports import confirm_transaction_candidate, detect_duplicate_candidates, list_transaction_candidates
from jarvis_finance.services.budget_transactions import confirm_budget_transaction
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations

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

def category(conn, name: str, typ: str = "expense", sort_order: int = 0) -> str:
    return confirm_create_category(conn, {"name": name, "category_type": typ, "sort_order": sort_order})["entity_id"]

def account(conn) -> str:
    return confirm_create_budget_account(conn, {"name": "Haushalt 2026", "account_type": "checking", "currency": "CHF"})["entity_id"]

def audit_count(conn, entity_type: str, entity_id: str) -> int:
    return int(conn.execute("SELECT COUNT(*) FROM audit_log WHERE entity_type=? AND entity_id=?", (entity_type, entity_id)).fetchone()[0] or 0)

def test_final_expense_categories_preview_and_confirm_preserves_income_and_sorts() -> None:
    conn = db()
    old_freizeit = category(conn, "Freizeit / Abos / Ausflüge", sort_order=80)
    old_vers = category(conn, "Versicherungen", sort_order=90)
    old_review = category(conn, "Review nötig", sort_order=999)
    income_id = category(conn, "Lohn Marcel", "income", 10)
    acc = account(conn)
    confirm_budget_transaction(conn, {"account_id": acc, "transaction_type": "expense", "transaction_date": "2026-01-10", "description": "Streaming", "amount_original": "1.00", "currency_original": "CHF", "category_id": old_freizeit})
    conn.execute("INSERT INTO budget_plan_items(plan_item_id, category_id, name, plan_month, amount_chf, cadence, is_active, created_at, updated_at) VALUES ('plan_old_vers', ?, 'Versicherung alt', '2026-01', '1.00', 'monthly', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", (old_vers,))
    conn.execute("INSERT INTO budget_review_rules(rule_id, merchant_contains, category_id, target_status, is_active, created_at, updated_at) VALUES ('rule_old_review', 'unklar', ?, 'review', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", (old_review,))
    conn.commit()

    preview = preview_budget_category_cleanup(conn)

    assert preview["target_expense_categories"] == FINAL_EXPENSE_CATEGORIES
    assert preview["active_expense_before_count"] >= 3
    assert preview["auto_mappable_count"] >= 2
    assert preview["review_required_count"] >= 1
    assert preview["income_category_count"] >= 1

    result = confirm_budget_category_cleanup(conn, preview["payload"])
    assert result["audit_id"]
    active_expense_names = [r["name"] for r in conn.execute("SELECT name FROM budget_categories WHERE category_type='expense' AND is_active=1 ORDER BY sort_order, name").fetchall()]
    assert active_expense_names == FINAL_EXPENSE_CATEGORIES
    assert conn.execute("SELECT is_active FROM budget_categories WHERE category_id=?", (income_id,)).fetchone()[0] == 1
    assert conn.execute("SELECT category_id FROM budget_transactions WHERE description='Streaming'").fetchone()[0] == conn.execute("SELECT category_id FROM budget_categories WHERE name='Hobbys & Selfcare' AND is_active=1").fetchone()[0]
    assert conn.execute("SELECT category_id FROM budget_plan_items WHERE plan_item_id='plan_old_vers'").fetchone()[0] == conn.execute("SELECT category_id FROM budget_categories WHERE name='Versicherungen Wohnen' AND is_active=1").fetchone()[0]

def test_category_crud_delete_archives_when_references_and_writes_audit() -> None:
    conn = db()
    empty = category(conn, "Temporär leer")
    used = category(conn, "Temporär genutzt")
    acc = account(conn)
    confirm_budget_transaction(conn, {"account_id": acc, "transaction_type": "expense", "transaction_date": "2026-02-10", "description": "Genutzt", "amount_original": "1.00", "currency_original": "CHF", "category_id": used})
    conn.execute("INSERT INTO budget_merchants(merchant_id, display_name, normalized_name, default_category_id, is_active, created_at, updated_at) VALUES ('merchant_used', 'Merchant Used', 'merchant used', ?, 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", (used,))
    conn.execute("INSERT INTO budget_review_rules(rule_id, merchant_contains, category_id, target_status, is_active, created_at, updated_at) VALUES ('rule_used', 'used', ?, 'review', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", (used,))
    conn.commit()

    renamed = update_category(conn, used, {"name": "Temporär genutzt neu", "change_note": "rename test"})
    assert renamed["audit_id"]
    assert conn.execute("SELECT name FROM budget_categories WHERE category_id=?", (used,)).fetchone()[0] == "Temporär genutzt neu"

    deleted = delete_empty_category(conn, empty)
    assert deleted["status"] == "deleted"
    assert audit_count(conn, "budget_category", empty) >= 1

    with pytest.raises(HTTPException):
        delete_empty_category(conn, used)
    archived = archive_category(conn, used)
    assert archived["status"] == "archived"
    assert conn.execute("SELECT is_active FROM budget_categories WHERE category_id=?", (used,)).fetchone()[0] == 0
    assert audit_count(conn, "budget_category", used) >= 2

def test_reorder_categories_is_idempotent_and_dropdown_order_uses_sort_order() -> None:
    conn = db()
    a = category(conn, "A Alt", sort_order=30)
    b = category(conn, "B Alt", sort_order=20)
    c = category(conn, "C Alt", sort_order=10)

    result = reorder_categories(conn, [b, c, a])
    again = reorder_categories(conn, [b, c, a])

    assert result["audit_id"]
    assert again["updated_count"] == 0
    rows = conn.execute("SELECT category_id FROM budget_categories WHERE category_type='expense' AND is_active=1 AND category_id IN (?,?,?) ORDER BY sort_order, name", (a, b, c)).fetchall()
    assert [r[0] for r in rows] == [b, c, a]

def test_duplicate_detection_auto_ignores_safe_duplicates_and_keeps_possible_duplicates_separate() -> None:
    conn = db()
    cat = category(conn, "Essen & Haushalt")
    acc = account(conn)
    tx = confirm_budget_transaction(conn, {"account_id": acc, "transaction_type": "expense", "transaction_date": "2026-03-10", "description": "Migros Windisch", "payee": "Migros Windisch", "amount_original": "1.00", "currency_original": "CHF", "category_id": cat})["entity_id"]
    conn.execute("INSERT INTO budget_transaction_candidates(transaction_candidate_id, source_file_label, source_type, transaction_date, description, merchant, amount_original, currency_original, proposed_category_id, proposed_category_name, duplicate_of_transaction_id, confidence, requires_review, status, classification, review_reason, created_at, updated_at) VALUES ('cand_safe_dup', 'visa.csv', 'credit_card_csv', '2026-03-10', 'Migros Windisch', 'Migros Windisch', '1.00', 'CHF', ?, 'Essen & Haushalt', NULL, '0.99', 0, 'pending', 'migros_auto_under_threshold', NULL, '2026-03-10T00:00:00Z', '2026-03-10T00:00:00Z')", (cat,))
    conn.execute("INSERT INTO budget_transaction_candidates(transaction_candidate_id, source_file_label, source_type, transaction_date, description, merchant, amount_original, currency_original, proposed_category_id, proposed_category_name, duplicate_of_transaction_id, confidence, requires_review, status, classification, review_reason, created_at, updated_at) VALUES ('cand_possible_dup', 'bank.csv', 'bank_csv', '2026-03-12', 'Migros Windisch maybe', 'Migros Windisch maybe', '1.00', 'CHF', ?, 'Essen & Haushalt', NULL, '0.70', 1, 'needs_review', 'bank_review', NULL, '2026-03-12T00:00:00Z', '2026-03-12T00:00:00Z')", (cat,))
    conn.commit()

    result = detect_duplicate_candidates(conn, day_window=2)

    assert result["safe_duplicate_count"] == 1
    assert result["possible_duplicate_count"] == 1
    safe = conn.execute("SELECT status, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE transaction_candidate_id='cand_safe_dup'").fetchone()
    possible = conn.execute("SELECT status, requires_review, duplicate_of_transaction_id FROM budget_transaction_candidates WHERE transaction_candidate_id='cand_possible_dup'").fetchone()
    assert safe["status"] == "auto_ignored_duplicate"
    assert safe["requires_review"] == 0
    assert safe["duplicate_of_transaction_id"] == tx
    assert possible["status"] == "duplicate_candidate"
    assert possible["requires_review"] == 1
    assert list_transaction_candidates(conn, budget_year="2026") == []
    assert [r["transaction_candidate_id"] for r in list_transaction_candidates(conn, tab="duplicates", budget_year="2026")] == ["cand_possible_dup"]
    assert [r["transaction_candidate_id"] for r in list_transaction_candidates(conn, tab="auto_ignored_duplicates", budget_year="2026")] == ["cand_safe_dup"]
    with pytest.raises(HTTPException):
        confirm_transaction_candidate(conn, "cand_possible_dup", {"account_id": acc, "category_id": cat, "budget_year": "2026"})


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_categories_cleanup_duplicates.py">
ERROR [8:5] "FINAL_EXPENSE_CATEGORIES" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [10:5] "confirm_budget_category_cleanup" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [13:5] "preview_budget_category_cleanup" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
ERROR [14:5] "reorder_categories" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
</diagnostics>


terminal

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


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