   820|        raise HTTPException(status_code=409, detail="candidate outside active budget year")
   821|    preview = preview_transaction_candidate(conn, candidate_id, payload)
   822|    result = confirm_budget_transaction(conn, preview["payload"])
   823|    ts = now()
   824|    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))
   825|    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")
   826|    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")
   827|    conn.commit()
   828|    return {"status": "confirmed", "entity_id": result["entity_id"], "audit_id": audit_id, "message": "Buchungskandidat übernommen"}
   829|
   830|
   831|def ignore_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   832|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   833|    if not row:
   834|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   835|    ts = now()
   836|    conn.execute("UPDATE budget_transaction_candidates SET status='ignored', notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (note, ts, candidate_id))
   837|    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")
   838|    conn.commit()
   839|    return {"status": "ignored", "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat ignoriert"}
   840|
   841|
   842|def reopen_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   843|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   844|    if not row:
   845|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   846|    if row["status"] == "confirmed":
   847|        raise HTTPException(status_code=409, detail="confirmed candidates cannot be reopened")
   848|    new_status = "needs_review" if row["requires_review"] else "pending"
   849|    ts = now()
   850|    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))
   851|    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")
   852|    conn.commit()
   853|    return {"status": new_status, "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat wieder geöffnet"}
   854|
   855|
   856|def update_transaction_candidate_category(conn: Connection, candidate_id: str, category_id: str, note: str | None = None) -> dict[str, Any]:
   857|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   858|    if not row:
   859|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   860|    if not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone():
   861|        raise HTTPException(status_code=422, detail="active category not found")
   862|    cname = _category_name(conn, category_id)
   863|    ts = now()
   864|    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))
   865|    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")
   866|    conn.commit()
   867|    return {"status": "updated", "entity_id": candidate_id, "audit_id": audit_id}
   868|
   869|
   870|def create_candidate_split(conn: Connection, candidate_id: str, splits: list[dict[str, Any]]) -> 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|    if not splits:
   875|        raise HTTPException(status_code=422, detail="splits required")
   876|    ts = now()
   877|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
   878|    for split in splits:
   879|        amount = _decimal_text(split.get("amount_original"))
   880|        if not amount:
   881|            raise HTTPException(status_code=422, detail="split amount required")
   882|        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))
   883|    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))
   884|    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")
   885|    conn.commit()
   886|    return {"status": "split_created", "entity_id": candidate_id, "split_count": len(splits), "audit_id": audit_id}
   887|
   888|
   889|def mark_candidate_transfer(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   890|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   891|    if not row:
   892|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   893|    ts = now()
   894|    conn.execute("UPDATE budget_transaction_candidates SET status='transfer_candidate', classification='transfer_candidate', requires_review=1, review_reason='user_marked_transfer', notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (note, ts, candidate_id))
   895|    audit_id = record_audit_event(conn, source="vue_dashboard", action="mark_transfer", entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"]}, new_values={"status": "transfer_candidate"}, user_text_note=note, created_by="user")
   896|    conn.commit()
   897|    return {"status": "transfer_candidate", "entity_id": candidate_id, "audit_id": audit_id}
   898|
   899|
   900|def bulk_update_transaction_candidate_category(conn: Connection, *, candidate_ids: list[str], category_id: str, note: str | None = None) -> dict[str, int]:
   901|    updated = skipped = 0
   902|    for cid in candidate_ids:
   903|        row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   904|        if not row or row["status"] == "confirmed":
   905|            skipped += 1
   906|            continue
   907|        update_transaction_candidate_category(conn, cid, category_id, note or "Bulk category assignment")
   908|        updated += 1
   909|    return {"updated_count": updated, "skipped_count": skipped}
   910|
   911|
   912|def _safe_auto_candidate_where() -> str:
   913|    return """
   914|        status IN ('pending','auto_categorized')
   915|        AND COALESCE(requires_review, 0)=0
   916|        AND duplicate_of_transaction_id IS NULL
   917|        AND COALESCE(covered_by_source, '')=''
   918|        AND (
   919|            classification='subscription_auto'
   920|            OR classification IN ('migros_auto_under_threshold','migros_receipt_food_household')
   921|            OR classification='auto_transport'
   922|        )
   923|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%galaxus%'
   924|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%digitec%'
   925|    """
   926|
   927|
   928|def preview_safe_auto_candidates(conn: Connection) -> dict[str, Any]:
   929|    rows = conn.execute(
   930|        f"""
   931|        SELECT classification, COUNT(*) AS count
   932|        FROM budget_transaction_candidates
   933|        WHERE {_safe_auto_candidate_where()}
   934|        GROUP BY classification
   935|        ORDER BY classification
   936|        """
   937|    ).fetchall()
   938|    total = sum(int(r["count"] or 0) for r in rows)
   939|    return {
   940|        "preview_id": new_id("preview"),
   941|        "summary": f"{total} sichere Auto-Kandidaten bereit für Confirm",
   942|        "count": total,
   943|        "groups": [row_to_dict(r) for r in rows],
   944|        "warnings": ["Preview only: Confirm benötigt Zielkonto und bucht nur sichere Kandidaten."],
   945|    }
   946|
   947|
   948|def batch_confirm_safe_candidates(conn: Connection, *, account_id: str, candidate_ids: list[str] | None = None) -> dict[str, int]:
   949|    if not account_id:
   950|        raise HTTPException(status_code=422, detail="account_id required")
   951|    allowed_rows = conn.execute(f"SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE {_safe_auto_candidate_where()}").fetchall()
   952|    allowed_ids = {str(r["transaction_candidate_id"]) for r in allowed_rows}
   953|    requested_ids = set(candidate_ids or allowed_ids)
   954|    confirmed = skipped = 0
   955|    for cid in sorted(requested_ids):
   956|        if cid not in allowed_ids:
   957|            skipped += 1
   958|            continue
   959|        confirm_transaction_candidate(conn, cid, {"account_id": account_id})
   960|        confirmed += 1
   961|    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")
   962|    conn.commit()
   963|    return {"confirmed_count": confirmed, "skipped_count": skipped}
   964|
   965|
   966|def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
   967|    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()
   968|    def count(pred):
   969|        return sum(1 for r in rows if pred(r))
   970|    return {
   971|        "open_total": count(lambda r: r["status"] != "covered_by_migros"),
   972|        "auto_categorized": count(lambda r: r["status"] in {"pending", "auto_categorized"} and not bool(r["requires_review"])),
   973|        "review_needed": count(lambda r: bool(r["requires_review"])),
   974|        "galaxus_digitec": count(lambda r: r["classification"] == "galaxus_review"),
   975|        "migros_over_50": count(lambda r: r["classification"] == "migros_review_over_threshold"),
   976|        "covered_by_migros": count(lambda r: r["status"] == "covered_by_migros"),
   977|        "unclear": count(lambda r: r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
   978|        "possible_duplicates": count(lambda r: r["status"] in {"duplicate", "duplicate_candidate"} or bool(r["duplicate_of_transaction_id"])),
   979|    }
   980|
   981|
   982|def preview_batch_confirm_candidates(conn: Connection, *, candidate_ids: list[str], account_id: str, budget_year: str = "2026") -> dict[str, Any]:
   983|    rows = []
   984|    conflicts = []
   985|    categories: dict[str, int] = {}
   986|    for cid in candidate_ids:
   987|        row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   988|        if not row:
   989|            conflicts.append({"candidate_id": cid, "reason": "missing"}); continue
   990|        if row["status"] in {"confirmed", "ignored", "covered_by_migros", "covered_by_source", "superseded", "reference_2025", "archived_reference"}:
   991|            conflicts.append({"candidate_id": cid, "reason": f"not_confirmable:{row['status']}"}); continue
   992|        if str(row["transaction_date"] or "")[:4] != str(budget_year):
   993|            conflicts.append({"candidate_id": cid, "reason": "outside_active_budget_year"}); continue
   994|        try:
   995|            preview_transaction_candidate(conn, cid, {"account_id": account_id, "category_id": row["proposed_category_id"], "budget_year": budget_year})
   996|        except Exception as exc:
   997|            conflicts.append({"candidate_id": cid, "reason": str(exc)}); continue
   998|        rows.append(row)
   999|        categories[str(row["proposed_category_name"] or "Review nötig")] = categories.get(str(row["proposed_category_name"] or "Review nötig"), 0) + 1
  1000|    if not rows and conflicts:
  1001|        raise HTTPException(status_code=409, detail={"message": "no confirmable candidates", "conflicts": conflicts})
  1002|    preview_id = new_id("batchprev")
  1003|    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")
  1004|    conn.commit()
  1005|    return {"preview_id": preview_id, "count": len(rows), "categories": categories, "conflicts": conflicts, "requires_explicit_confirm": True}
  1006|
  1007|
  1008|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]:
  1009|    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": []}
  1010|    if action == "confirm" and not preview_id:
  1011|        raise HTTPException(status_code=422, detail="batch confirm requires preview_id")
  1012|    for cid in candidate_ids:
  1013|        try:
  1014|            row_for_year = conn.execute("SELECT transaction_date FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1015|            if not row_for_year:
  1016|                raise HTTPException(status_code=404, detail="candidate not found")
  1017|            if str(row_for_year["transaction_date"] or "")[:4] != str(budget_year):
  1018|                raise HTTPException(status_code=409, detail="candidate outside active budget year")
  1019|            if action == "category":
  1020|                if not category_id: raise HTTPException(status_code=422, detail="category_id required")
  1021|                update_transaction_candidate_category(conn, cid, category_id, note or "Batch category assignment"); counts["updated_count"] += 1
  1022|            elif action == "confirm":
  1023|                confirm_transaction_candidate(conn, cid, {"account_id": account_id, "budget_year": budget_year}); counts["confirmed_count"] += 1
  1024|            elif action == "ignore":
  1025|                ignore_transaction_candidate(conn, cid, note or "Batch ignore"); counts["ignored_count"] += 1
  1026|            elif action == "reopen":
  1027|                reopen_transaction_candidate(conn, cid, note or "Batch reopen"); counts["reopened_count"] += 1
  1028|            elif action == "transfer":
  1029|                mark_candidate_transfer(conn, cid, note or "Batch transfer"); counts["transfer_count"] += 1
  1030|            elif action == "covered_by_migros":
  1031|                ts = now(); row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1032|                if not row: raise HTTPException(status_code=404, detail="candidate not found")
  1033|                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))
  1034|                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")
  1035|                counts["covered_by_migros_count"] += 1
  1036|            else:
  1037|                raise HTTPException(status_code=422, detail="invalid batch action")
  1038|        except Exception as exc:
  1039|            counts["skipped_count"] += 1
  1040|            detail = getattr(exc, "detail", str(exc))
  1041|            counts["errors"].append({"candidate_id": cid, "error": str(detail)})
  1042|    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")
  1043|    conn.commit()
  1044|    return counts | {"audit_id": audit_id}
  1045|
  1046|
  1047|def confirm_candidate_split(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
  1048|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
  1049|    if not row:
  1050|        raise HTTPException(status_code=404, detail="transaction candidate not found")
  1051|    if row["status"] in {"confirmed", "ignored", "covered_by_migros", "superseded"}:
  1052|        raise HTTPException(status_code=409, detail="candidate is not splittable")
  1053|    splits = payload.get("splits") or []
  1054|    if not payload.get("account_id"):
  1055|        raise HTTPException(status_code=422, detail="account_id required")
  1056|    if not splits:
  1057|        raise HTTPException(status_code=422, detail="splits required")
  1058|    total = sum(Decimal(_decimal_text(s.get("amount_original")) or "0") for s in splits)
  1059|    candidate_amount = Decimal(str(row["amount_original"] or "0"))
  1060|    if total != candidate_amount:
  1061|        raise HTTPException(status_code=422, detail="split sum must match candidate amount")
  1062|    group_id = new_id("splitgrp")
  1063|    ts = now()
  1064|    created_ids = []
  1065|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
  1066|    for idx, split in enumerate(splits, start=1):
  1067|        amount = _decimal_text(split.get("amount_original"))
  1068|        cat = split.get("category_id")
  1069|        if not cat or not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat,)).fetchone():
  1070|            raise HTTPException(status_code=422, detail="active split category required")
  1071|        result = confirm_budget_transaction(conn, {
  1072|            "account_id": payload["account_id"], "transaction_type": payload.get("transaction_type") or "expense", "transaction_date": row["transaction_date"],
  1073|            "description": f"{row['description']} · Split {idx}", "amount_original": amount, "currency_original": row["currency_original"],
  1074|            "category_id": cat, "source_type": "import_candidate", "source_candidate_id": candidate_id,
  1075|            "notes": f"Split group {group_id}: {split.get('note') or ''}", "tag_names": [split.get("tag")] if split.get("tag") else [],
  1076|        })
  1077|        created_ids.append(result["entity_id"])
  1078|        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))
  1079|    primary_confirmed_id = created_ids[0] if created_ids else group_id
  1080|    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))
  1081|    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")
  1082|    conn.commit()
  1083|    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}
  1084|
  1085|
  1086|def create_review_category_for_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
  1087|    if not conn.execute("SELECT 1 FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone():
  1088|        raise HTTPException(status_code=404, detail="transaction candidate not found")
  1089|    name = str(payload.get("name") or "").strip()
  1090|    if not name:
  1091|        raise HTTPException(status_code=422, detail="category name required")
  1092|    ts = now(); category_id = new_id("bcat")
  1093|    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))
  1094|    update_transaction_candidate_category(conn, candidate_id, category_id, "Created from review")
  1095|    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")
  1096|    conn.commit()
  1097|    return {"status": "created", "category_id": category_id, "audit_id": audit_id}
  1098|
  1099|
  1100|def create_budget_rule(conn: Connection, payload: dict[str, Any]) -> dict[str, Any]:
  1101|    text = str(payload.get("merchant_contains") or "").strip()
  1102|    if not text:
  1103|        raise HTTPException(status_code=422, detail="merchant_contains required")
  1104|    target_status = payload.get("target_status") or "review"
  1105|    if target_status not in {"auto", "review"}:
  1106|        raise HTTPException(status_code=422, detail="invalid target_status")
  1107|    priority = int(payload.get("priority") or 100)
  1108|    confidence = str(payload.get("confidence") or "0.82")
  1109|    rule_id = new_id("brule"); ts = now()
  1110|    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")))
  1111|    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")
  1112|    conn.commit()
  1113|    return {"status": "created", "rule_id": rule_id, "audit_id": audit_id}
  1114|
  1115|
  1116|def list_budget_rules(conn: Connection, *, include_inactive: bool = False) -> list[dict[str, Any]]:
  1117|    where = "" if include_inactive else "WHERE r.is_active=1"
  1118|    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()
  1119|    return [row_to_dict(r) | {"is_active": bool(r["is_active"])} for r in rows]
  1120|
  1121|
  1122|def _rule_match_rows(conn: Connection, rule_id: str):
  1123|    rule = conn.execute("SELECT * FROM budget_review_rules WHERE rule_id=?", (rule_id,)).fetchone()
  1124|    if not rule:
  1125|        raise HTTPException(status_code=404, detail="rule not found")
  1126|    clauses = ["status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','superseded','reference_2025','archived_reference')", "lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"]
  1127|    params: list[Any] = [f"%{str(rule['merchant_contains']).lower()}%"]
  1128|    if rule["source_type"]:
  1129|        clauses.append("source_type=?"); params.append(rule["source_type"])
  1130|    return rule, conn.execute("SELECT * FROM budget_transaction_candidates WHERE " + " AND ".join(clauses), params).fetchall()
  1131|
  1132|
  1133|def test_budget_rule(conn: Connection, rule_id: str) -> dict[str, Any]:
  1134|    rule, rows = _rule_match_rows(conn, rule_id)
  1135|    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]}
  1136|
  1137|
  1138|def apply_budget_rules_to_open_candidates(conn: Connection, rule_id: str) -> dict[str, Any]:
  1139|    rule, rows = _rule_match_rows(conn, rule_id)
  1140|    if not rule["is_active"]:
  1141|        raise HTTPException(status_code=409, detail="rule inactive")
  1142|    status = "auto_categorized" if rule["target_status"] == "auto" else "needs_review"
  1143|    requires_review = 0 if rule["target_status"] == "auto" else 1
  1144|    cname = _category_name(conn, rule["category_id"])
  1145|    ts = now(); updated = 0
  1146|    for row in rows:
  1147|        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"]))
  1148|        updated += 1
  1149|    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")
  1150|    conn.commit()
  1151|    return {"updated_count": updated, "audit_id": audit_id, "productive_transaction_count": 0}
  1152|
  1153|
  1154|def update_budget_rule(conn: Connection, rule_id: str, payload: dict[str, Any]) -> dict[str, Any]:
  1155|    row = conn.execute("SELECT * FROM budget_review_rules WHERE rule_id=?", (rule_id,)).fetchone()
  1156|    if not row:
  1157|        raise HTTPException(status_code=404, detail="rule not found")
  1158|    allowed = {"merchant_contains", "source_type", "category_id", "target_status", "priority", "confidence", "notes", "is_active"}
  1159|    updates: dict[str, Any] = {k: payload[k] for k in allowed if k in payload}
  1160|    if "target_status" in updates and updates["target_status"] not in {"auto", "review"}:
  1161|        raise HTTPException(status_code=422, detail="invalid target_status")
  1162|    if "merchant_contains" in updates and not str(updates["merchant_contains"] or "").strip():
  1163|        raise HTTPException(status_code=422, detail="merchant_contains required")
  1164|    if not updates:
  1165|        raise HTTPException(status_code=422, detail="no editable fields supplied")
  1166|    updates["updated_at"] = now()
  1167|    set_clause = ", ".join([f"{k}=?" for k in updates])
  1168|    conn.execute(f"UPDATE budget_review_rules SET {set_clause} WHERE rule_id=?", [updates[k] for k in updates] + [rule_id])
  1169|    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")
  1170|    conn.commit()
  1171|    return {"status": "updated", "rule_id": rule_id, "audit_id": audit_id}
  1172|
  1173|
  1174|def prioritize_budget_rule(conn: Connection, rule_id: str, priority: int) -> dict[str, Any]:
  1175|    return update_budget_rule(conn, rule_id, {"priority": int(priority)}) | {"priority": int(priority)}
  1176|
  1177|
  1178|def deactivate_budget_rule(conn: Connection, rule_id: str) -> dict[str, Any]:
  1179|    row = conn.execute("SELECT is_active FROM budget_review_rules WHERE rule_id=?", (rule_id,)).fetchone()
  1180|    if not row:
  1181|        raise HTTPException(status_code=404, detail="rule not found")
  1182|    ts = now(); conn.execute("UPDATE budget_review_rules SET is_active=0, updated_at=? WHERE rule_id=?", (ts, rule_id))
  1183|    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")
  1184|    conn.commit(); return {"status": "deactivated", "rule_id": rule_id, "audit_id": audit_id}
  1185|
  1186|
  1187|def create_merchant(conn: Connection, payload: dict[str, Any]) -> dict[str, Any]:
  1188|    display_name = str(payload.get("display_name") or "").strip()
  1189|    if not display_name:
  1190|        raise HTTPException(status_code=422, detail="display_name required")
  1191|    default_category_id = payload.get("default_category_id") or None
  1192|    if default_category_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (default_category_id,)).fetchone():
  1193|        raise HTTPException(status_code=422, detail="active default category required")
  1194|    merchant_id = new_id("bmerch")
  1195|    ts = now()
  1196|    normalized = _normalized_merchant_name(display_name)
  1197|    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))
  1198|    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")
  1199|    conn.commit()
  1200|    return {"status": "created", "merchant_id": merchant_id, "audit_id": audit_id}
  1201|
  1202|
  1203|def list_merchants(conn: Connection, *, include_inactive: bool = False) -> list[dict[str, Any]]:
  1204|    where = "" if include_inactive else "WHERE m.is_active=1"
  1205|    rows = conn.execute(f"""
  1206|        SELECT m.*, c.name AS default_category_name,
  1207|          (SELECT COUNT(*) FROM budget_merchant_aliases a WHERE a.merchant_id=m.merchant_id AND a.is_active=1) AS alias_count,
  1208|          (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
  1209|        FROM budget_merchants m LEFT JOIN budget_categories c ON c.category_id=m.default_category_id
  1210|        {where} ORDER BY m.is_active DESC, lower(m.display_name)
  1211|    """).fetchall()
  1212|    return [row_to_dict(r) | {"is_active": bool(r["is_active"])} for r in rows]
  1213|
  1214|
  1215|def create_merchant_alias(conn: Connection, merchant_id: str, payload: dict[str, Any]) -> dict[str, Any]:
  1216|    if not conn.execute("SELECT 1 FROM budget_merchants WHERE merchant_id=? AND is_active=1", (merchant_id,)).fetchone():
  1217|        raise HTTPException(status_code=404, detail="merchant not found")
  1218|    pattern = str(payload.get("pattern") or "").strip()
  1219|    match_type = payload.get("match_type") or "contains"
  1220|    if not pattern:
  1221|        raise HTTPException(status_code=422, detail="pattern required")
  1222|    if match_type not in {"contains", "exact", "regex"}:
  1223|        raise HTTPException(status_code=422, detail="invalid match_type")
  1224|    alias_id = new_id("bmalias")
  1225|    ts = now()
  1226|    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))
  1227|    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")
  1228|    conn.commit()
  1229|    return {"status": "created", "alias_id": alias_id, "audit_id": audit_id}
  1230|
  1231|
  1232|def update_merchant_category(conn: Connection, merchant_id: str, category_id: str | None) -> dict[str, Any]:
  1233|    row = conn.execute("SELECT * FROM budget_merchants WHERE merchant_id=?", (merchant_id,)).fetchone()
  1234|    if not row:
  1235|        raise HTTPException(status_code=404, detail="merchant not found")
  1236|    if category_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone():
  1237|        raise HTTPException(status_code=422, detail="active category required")
  1238|    ts = now()
  1239|    conn.execute("UPDATE budget_merchants SET default_category_id=?, updated_at=? WHERE merchant_id=?", (category_id, ts, merchant_id))
  1240|    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")
  1241|    conn.commit()
  1242|    return {"status": "updated", "merchant_id": merchant_id, "audit_id": audit_id}
  1243|
  1244|
  1245|def _open_candidates_for_merchants(conn: Connection):
  1246|    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()
  1247|
  1248|
  1249|def apply_merchant_to_open_candidates(conn: Connection, merchant_id: str | None = None) -> dict[str, Any]:
  1250|    merchant_clause = "AND m.merchant_id=?" if merchant_id else ""
  1251|    params: list[Any] = [merchant_id] if merchant_id else []
  1252|    aliases = conn.execute(f"""
  1253|        SELECT a.*, m.display_name, m.default_category_id, c.name AS default_category_name
  1254|        FROM budget_merchant_aliases a
  1255|        JOIN budget_merchants m ON m.merchant_id=a.merchant_id AND m.is_active=1
  1256|        LEFT JOIN budget_categories c ON c.category_id=m.default_category_id
  1257|        WHERE a.is_active=1 {merchant_clause}
  1258|        ORDER BY a.priority ASC, length(a.pattern) DESC
  1259|    """, params).fetchall()
  1260|    updated = 0
  1261|    ts = now()
  1262|    for cand in _open_candidates_for_merchants(conn):
  1263|        for alias in aliases:
  1264|            if not _merchant_alias_matches(alias, cand):
  1265|                continue
  1266|            status = "auto_categorized" if alias["default_category_id"] else "needs_review"
  1267|            requires_review = 0 if alias["default_category_id"] else 1
  1268|            conn.execute("""
  1269|                UPDATE budget_transaction_candidates
  1270|                SET merchant_id=?, merchant_display_name=?, merchant=?, proposed_category_id=COALESCE(?, proposed_category_id), proposed_category_name=COALESCE(?, proposed_category_name),
  1271|                    status=?, status_label=?, requires_review=?, review_reason='merchant_alias_match', confidence='0.90', updated_at=?
  1272|                WHERE transaction_candidate_id=?
  1273|            """, (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"]))
  1274|            updated += 1
  1275|            break
  1276|    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")
  1277|    conn.commit()
  1278|    return {"updated_count": updated, "audit_id": audit_id}
  1279|
  1280|
  1281|def merge_merchants(conn: Connection, source_merchant_id: str, target_merchant_id: str) -> dict[str, Any]:
  1282|    if source_merchant_id == target_merchant_id:
  1283|        raise HTTPException(status_code=422, detail="source and target must differ")
  1284|    source = conn.execute("SELECT * FROM budget_merchants WHERE merchant_id=?", (source_merchant_id,)).fetchone()
  1285|    target = conn.execute("SELECT * FROM budget_merchants WHERE merchant_id=?", (target_merchant_id,)).fetchone()
  1286|    if not source or not target:
  1287|        raise HTTPException(status_code=404, detail="merchant not found")
  1288|    ts = now()
  1289|    conn.execute("UPDATE budget_merchant_aliases SET merchant_id=?, updated_at=? WHERE merchant_id=?", (target_merchant_id, ts, source_merchant_id))
  1290|    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))
  1291|    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))
  1292|    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")
  1293|    conn.commit()
  1294|    return {"status": "merged", "source_merchant_id": source_merchant_id, "target_merchant_id": target_merchant_id, "audit_id": audit_id}
  1295|
  1296|
  1297|def _similar_merchant(a: str, b: str) -> bool:
  1298|    aa = set(_normalized_merchant_name(a).split())
  1299|    bb = set(_normalized_merchant_name(b).split())
  1300|    if not aa or not bb:
  1301|        return False
  1302|    return bool(aa & bb) or _normalized_merchant_name(a) in _normalized_merchant_name(b) or _normalized_merchant_name(b) in _normalized_merchant_name(a)
  1303|
  1304|
  1305|def detect_duplicate_candidates(conn: Connection, *, day_window: int = 2) -> dict[str, Any]:
  1306|    candidates = conn.execute("""
  1307|        SELECT * FROM budget_transaction_candidates
  1308|        WHERE status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','superseded','reference_2025','archived_reference')
  1309|          AND transaction_date IS NOT NULL AND amount_original IS NOT NULL
  1310|    """).fetchall()
  1311|    transactions = conn.execute("SELECT * FROM budget_transactions WHERE status='confirmed' AND transaction_date IS NOT NULL").fetchall()
  1312|    updated = 0
  1313|    ts = now()
  1314|    for cand in candidates:
  1315|        try:
  1316|            cdate = datetime.fromisoformat(str(cand["transaction_date"])[:10])
  1317|            camount = Decimal(str(cand["amount_original"]))
  1318|        except Exception:
  1319|            continue
  1320|

Use offset=1320 to continue reading (showing 820-1319 of 1366 lines)


   730|            "duplicates": "status IN ('duplicate','duplicate_candidate')",
   731|            "transfers": "classification='transfer_candidate' OR status='transfer_candidate'",
   732|            "bank": "source_type='bank_csv'",
   733|            "income": "classification='income_candidate'",
   734|            "ignored": "status IN ('ignored','covered_by_migros','covered_by_source','superseded','reference_2025','archived_reference')",
   735|        }
   736|        if tab in mapping:
   737|            clauses.append(mapping[tab])
   738|    if source_type:
   739|        clauses.append("source_type=?"); params.append(source_type)
   740|    if category_id:
   741|        clauses.append("proposed_category_id=?"); params.append(category_id)
   742|    if merchant:
   743|        clauses.append("lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"); params.append(f"%{merchant.lower()}%")
   744|    if rule:
   745|        clauses.append("lower(COALESCE(rule_id,'') || ' ' || COALESCE(rule_name,'') || ' ' || COALESCE(review_reason,'')) LIKE ?"); params.append(f"%{rule.lower()}%")
   746|    if min_confidence:
   747|        clauses.append("CAST(COALESCE(confidence,'0') AS REAL) >= CAST(? AS REAL)"); params.append(min_confidence)
   748|    if date_from:
   749|        clauses.append("transaction_date>=?"); params.append(date_from)
   750|    if date_to:
   751|        clauses.append("transaction_date<=?"); params.append(date_to)
   752|    if amount_min:
   753|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) >= CAST(? AS REAL)"); params.append(amount_min)
   754|    if amount_max:
   755|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) <= CAST(? AS REAL)"); params.append(amount_max)
   756|    where = "WHERE " + " AND ".join(clauses) if clauses else ""
   757|    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"}
   758|    order_col = sort_map.get(sort_by, "created_at")
   759|    direction = "ASC" if sort_dir.lower() == "asc" else "DESC"
   760|    rows = conn.execute(f"SELECT * FROM budget_transaction_candidates {where} ORDER BY {order_col} {direction}, created_at DESC LIMIT 500", params).fetchall()
   761|    return [_candidate_view(r) for r in rows]
   762|
   763|
   764|def preview_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
   765|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   766|    if not row:
   767|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   768|    payload = payload or {}
   769|    cat_id = payload.get("category_id") or row["proposed_category_id"]
   770|    notes = row["notes"] or ""
   771|    tx_type = payload.get("transaction_type") or "expense"
   772|    try:
   773|        meta = json.loads(notes) if notes and notes.startswith("{") else {}
   774|        tx_type = payload.get("transaction_type") or meta.get("transaction_type") or tx_type
   775|    except json.JSONDecodeError:
   776|        pass
   777|    if not cat_id and tx_type in {"expense", "income"}:
   778|        raise HTTPException(status_code=422, detail="category required before confirm")
   779|    if cat_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat_id,)).fetchone():
   780|        raise HTTPException(status_code=422, detail="active category required before confirm")
   781|    if not payload.get("account_id"):
   782|        raise HTTPException(status_code=422, detail="account_id required before confirm")
   783|    if not row["transaction_date"]:
   784|        raise HTTPException(status_code=422, detail="transaction date required before confirm")
   785|    if not row["amount_original"]:
   786|        raise HTTPException(status_code=422, detail="amount required before confirm")
   787|    if not row["currency_original"]:
   788|        raise HTTPException(status_code=422, detail="currency required before confirm")
   789|    return {
   790|        "preview_id": new_id("preview"),
   791|        "summary": "Buchungskandidat übernehmen",
   792|        "warnings": ["Möglicher Duplikat-Treffer"] if row["duplicate_of_transaction_id"] else ([] if cat_id or tx_type == "transfer" else ["Kategorie fehlt"]),
   793|        "payload": {
   794|            "account_id": payload.get("account_id"),
   795|            "transaction_type": tx_type,
   796|            "transaction_date": payload.get("transaction_date") or row["transaction_date"],
   797|            "description": payload.get("description") or row["description"],
   798|            "payee": payload.get("payee") or row["merchant"],
   799|            "amount_original": row["amount_original"],
   800|            "currency_original": row["currency_original"],
   801|            "category_id": cat_id,
   802|            "source_type": "import_candidate",
   803|            "source_candidate_id": candidate_id,
   804|            "notes": f"Confirmed from transaction candidate {candidate_id}",
   805|        },
   806|    }
   807|
   808|
   809|def confirm_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   810|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   811|    if not row:
   812|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   813|    if row["status"] == "confirmed":
   814|        raise HTTPException(status_code=409, detail="candidate already confirmed")
   815|    if row["status"] in {"covered_by_migros", "covered_by_source", "superseded", "ignored", "reference_2025", "archived_reference"}:
   816|        raise HTTPException(status_code=409, detail="candidate is not confirmable")
   817|    if row["status"] in {"duplicate", "duplicate_candidate"} and not payload.get("allow_duplicate"):
   818|        raise HTTPException(status_code=409, detail="duplicate candidate requires explicit allow_duplicate")
   819|    if str(row["transaction_date"] or "")[:4] != str(payload.get("budget_year") or "2026"):
   820|

Use offset=820 to continue reading (showing 730-819 of 1366 lines)


Explanation:
**Looking into database setup**

I think I need to create a test database and maybe use seed functions to do that. First off, I should check if I need to set up a database connection, which seems like it might be in the database.py file. It's important to have everything properly configured to ensure the test database works as intended. Let’s lay out the steps needed to get this all sorted!


     1|from __future__ import annotations
     2|
     3|import sqlite3
     4|from pathlib import Path
     5|
     6|
     7|def connect(db_path: str | Path) -> sqlite3.Connection:
     8|    path = Path(db_path).expanduser()
     9|    path.parent.mkdir(parents=True, exist_ok=True)
    10|    conn = sqlite3.connect(path, check_same_thread=False)
    11|    conn.row_factory = sqlite3.Row
    12|    conn.execute("PRAGMA foreign_keys = ON")
    13|    return conn
    14|
    15|
    16|def connect_memory() -> sqlite3.Connection:
    17|    conn = sqlite3.connect(":memory:", check_same_thread=False)
    18|    conn.row_factory = sqlite3.Row
    19|    conn.execute("PRAGMA foreign_keys = ON")
    20|    return conn
    21|


Explanation:
**Investigating search issues**

I'm noticing that the search isn't functioning properly, and I'm questioning whether it might be a pattern case causing the problem. To get to the bottom of this, I think I should list the service files. That could help me identify if there's anything peculiar going on. I want to ensure that I find the right solution so the search feature can work smoothly again! Let's take it step by step.


     1|from __future__ import annotations
     2|
     3|from sqlite3 import Connection
     4|
     5|from jarvis_finance.api.schemas.overview import CashSummary
     6|from jarvis_finance.api.schemas.positions import CashPosition
     7|from jarvis_finance.dashboard import data as dashboard_data
     8|from jarvis_finance.services.api_helpers import decimal_text, optional_decimal_text, status_label
     9|
    10|
    11|def list_cash_positions(conn: Connection) -> list[CashPosition]:
    12|    positions: list[CashPosition] = []
    13|    for row in dashboard_data.get_cash_overview(conn):
    14|        currency = row.get("currency", "")
    15|        amount = row.get("amount_original", "0")
    16|        positions.append(
    17|            CashPosition(
    18|                id=f"{row.get('account_id')}:{currency}",
    19|                platform=row.get("platform", ""),
    20|                account_label=row.get("account_name", ""),
    21|                currency=currency,
    22|                amount=decimal_text(amount),
    23|                amount_chf=optional_decimal_text(row.get("amount_chf"), 2),
    24|                status=status_label(row.get("quality_status")),
    25|            )
    26|        )
    27|    return positions
    28|
    29|
    30|def get_cash_summary(conn: Connection) -> CashSummary:
    31|    positions = list_cash_positions(conn)
    32|    total = sum(dashboard_data.d(p.amount_chf) for p in positions if p.amount_chf)
    33|    return CashSummary(
    34|        base_currency="CHF",
    35|        cash_chf=decimal_text(total, 2),
    36|        positions=positions,
    37|    )
    38|


     1|from __future__ import annotations
     2|
     3|from datetime import UTC, datetime
     4|from decimal import Decimal, InvalidOperation
     5|import json
     6|from sqlite3 import Connection
     7|from uuid import uuid4
     8|
     9|from fastapi import HTTPException
    10|
    11|from jarvis_finance.api.schemas.positions import (
    12|    ActionItem,
    13|    AuditEntry,
    14|    CashConfirmRequest,
    15|    CashPositionDetail,
    16|    CashPreviewRequest,
    17|    CleanupPreviewResponse,
    18|    ConfirmResponse,
    19|    ContainerConfirmRequest,
    20|    ContainerPreviewRequest,
    21|    CryptoActionConfirmRequest,
    22|    CryptoActionPreviewRequest,
    23|    EquityDividendConfirmRequest,
    24|    EquityDividendPreviewRequest,
    25|    EquitySellConfirmRequest,
    26|    EquitySellPreviewRequest,
    27|    GenericConfirmRequest,
    28|    InstrumentSearchCandidate,
    29|    InstrumentSearchRequest,
    30|    PositionConfirmRequest,
    31|    PositionPreviewRequest,
    32|    PreviewResponse,
    33|    PricePoint,
    34|    ProviderSearchResponse,
    35|    ReferenceAccount,
    36|    RemovePositionPreviewResponse,
    37|)
    38|from jarvis_finance.equity.cleanup import apply_review_test_equity_cleanup, identify_review_test_equity_artifacts
    39|from jarvis_finance.fx.rates import resolve_fx_rate_to_chf
    40|from jarvis_finance.imports.common import stable_id
    41|from jarvis_finance.market_data.catalog import (
    42|    CatalogSearchResult,
    43|    FmpSearchProvider,
    44|    FinnhubSymbolLookupProvider,
    45|    MassiveLookupProvider,
    46|    OpenFigiLookupProvider,
    47|    TwelveDataLookupProvider,
    48|    search_instruments as search_catalog_instruments,
    49|)
    50|from jarvis_finance.services.api_helpers import decimal_text, optional_decimal_text
    51|from jarvis_finance.services.cash_service import get_cash_summary
    52|from jarvis_finance.services.equity_service import get_equity_position
    53|
    54|
    55|def _now() -> str:
    56|    return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
    57|
    58|
    59|def _uuid(prefix: str) -> str:
    60|    return f"{prefix}_{uuid4().hex[:16]}"
    61|
    62|
    63|def _decimal(value: str, field: str) -> Decimal:
    64|    try:
    65|        parsed = Decimal(str(value))
    66|    except (InvalidOperation, ValueError) as exc:
    67|        raise HTTPException(status_code=422, detail=f"Invalid decimal for {field}") from exc
    68|    if parsed <= 0:
    69|        raise HTTPException(status_code=422, detail=f"{field} must be positive")
    70|    return parsed
    71|
    72|
    73|def _decimal_non_negative(value: str | None, field: str) -> Decimal:
    74|    if value in (None, ""):
    75|        return Decimal("0")
    76|    try:
    77|        parsed = Decimal(str(value))
    78|    except (InvalidOperation, ValueError) as exc:
    79|        raise HTTPException(status_code=422, detail=f"Invalid decimal for {field}") from exc
    80|    if parsed < 0:
    81|        raise HTTPException(status_code=422, detail=f"{field} must be zero or positive")
    82|    return parsed
    83|
    84|
    85|def _normalise_transaction_type(value: str, asset_class: str | None = None) -> str:
    86|    key = value.strip().lower().replace(" ", "_").replace("-", "_")
    87|    if key in {"initial_snapshot", "initial"}:
    88|        return "initial_cash_snapshot" if asset_class == "cash" else "initial_position_snapshot"
    89|    mapping = {
    90|        "kauf": "buy",
    91|        "buy": "buy",
    92|        "cash_einzahlung": "cash_deposit",
    93|        "cash_deposit": "cash_deposit",
    94|        "einzahlung": "cash_deposit",
    95|        "cash_auszahlung": "cash_withdrawal",
    96|        "cash_withdrawal": "cash_withdrawal",
    97|        "auszahlung": "cash_withdrawal",
    98|        "korrektur": "manual_cash_correction",
    99|        "cash_korrektur": "manual_cash_correction",
   100|        "manual_cash_correction": "manual_cash_correction",
   101|    }
   102|    return mapping.get(key, key)
   103|
   104|
   105|def _audit(conn: Connection, *, action: str, entity_type: str, entity_id: str, payload: dict, note: str, quality_status: str = "ok") -> str:
   106|    audit_id = _uuid("audit")
   107|    ts = _now()
   108|    conn.execute(
   109|        """
   110|        INSERT INTO audit_log(audit_id, timestamp, source, action, entity_type, entity_id, old_values_json, new_values_json, user_text_note, confirmed, confirmation_timestamp, created_by, quality_status, created_at)
   111|        VALUES (?, ?, 'vue_dashboard', ?, ?, ?, NULL, ?, ?, 1, ?, 'user', ?, ?)
   112|        """,
   113|        (audit_id, ts, action, entity_type, entity_id, json.dumps(payload, sort_keys=True), note, ts, quality_status, ts),
   114|    )
   115|    return audit_id
   116|
   117|
   118|def list_reference_accounts(conn: Connection) -> list[ReferenceAccount]:
   119|    rows = conn.execute(
   120|        """
   121|        SELECT a.account_id, p.name AS platform, a.account_name, a.account_type, a.currency
   122|        FROM accounts a JOIN platforms p ON p.platform_id = a.platform_id
   123|        WHERE a.is_active = 1
   124|        ORDER BY p.name, a.account_name
   125|        """
   126|    ).fetchall()
   127|    return [ReferenceAccount(account_id=r["account_id"], label=f"{r['platform']} · {r['account_name']}", account_type=r["account_type"], currency=r["currency"]) for r in rows]
   128|
   129|
   130|def search_instruments(conn: Connection, request: InstrumentSearchRequest) -> list[InstrumentSearchCandidate]:
   131|    q = f"%{request.query.strip().lower()}%"
   132|    asset = request.asset_class.lower()
   133|    allowed = ("stock", "etf") if asset in {"stock", "equity", "etf"} else (asset,)
   134|    rows = conn.execute(
   135|        f"""
   136|        SELECT instrument_id, asset_class, name, ticker, isin, exchange, currency
   137|        FROM instruments
   138|        WHERE is_active = 1 AND lower(asset_class) IN ({','.join('?' for _ in allowed)})
   139|          AND (lower(name) LIKE ? OR lower(ticker) LIKE ? OR lower(isin) LIKE ?)
   140|        ORDER BY name
   141|        LIMIT 12
   142|        """,
   143|        (*allowed, q, q, q),
   144|    ).fetchall()
   145|    return [
   146|        InstrumentSearchCandidate(
   147|            candidate_id=r["instrument_id"],
   148|            label=r["name"],
   149|            asset_class=r["asset_class"],
   150|            isin=r["isin"] or "",
   151|            ticker=r["ticker"] or "",
   152|            exchange=r["exchange"] or "",
   153|            currency=r["currency"] or "",
   154|            provider="local",
   155|            confidence_label="Lokal gespeichert",
   156|            review_required=False,
   157|        )
   158|        for r in rows
   159|    ]
   160|
   161|
   162|def _chf_amount(amount: str | None, rate: Decimal | None) -> str | None:
   163|    if amount in (None, "") or rate is None:
   164|        return None
   165|    return decimal_text(Decimal(str(amount)) * rate, 2)
   166|
   167|
   168|def _fx_for_explicit_save(conn: Connection, *, currency: str, trade_date: str | None) -> tuple[str, str | None, str | None, str | None]:
   169|    cur = currency.upper()
   170|    result = resolve_fx_rate_to_chf(conn, base_currency=cur, rate_date=trade_date, persist=True, resolve_fixed=True)
   171|    if result.rate is None:
   172|        return "missing", None, None, result.warning or "FX fehlt: Speichern bleibt möglich, CHF-Wert bleibt leer."
   173|    status = "not_needed" if cur == "CHF" else "ok"
   174|    source = result.source
   175|    return status, format(result.rate, "f"), source, None
   176|
   177|
   178|def _provider_search_providers():
   179|    return [FmpSearchProvider(), FinnhubSymbolLookupProvider(), TwelveDataLookupProvider(), OpenFigiLookupProvider(), MassiveLookupProvider()]
   180|
   181|
   182|_DERIVATIVE_TOKENS = ("CDR", "ADR", "GDR", "WARRANT", "CERTIFICATE", "DERIVATIVE", "RIGHT", "NOTE")
   183|_MAIN_EXCHANGES = {"NASDAQ", "NYSE", "NYSEARCA", "SIX", "XETRA", "LSE", "TSX"}
   184|
   185|
   186|def _is_derivative_like(result: CatalogSearchResult) -> bool:
   187|    text = " ".join([result.name or "", result.security_type or "", result.evidence_note or ""]).upper()
   188|    return any(token in text for token in _DERIVATIVE_TOKENS)
   189|
   190|
   191|def _confidence_label(result: CatalogSearchResult) -> str:
   192|    if "provider conflict" in (result.evidence_note or "").lower():
   193|        return "Manuell prüfen"
   194|    if result.confidence == "high":
   195|        return "Eindeutig"
   196|    if result.confidence == "medium":
   197|        return "Wahrscheinlich"
   198|    if result.ticker or result.isin:
   199|        return "Unsicher"
   200|    return "Manuell prüfen"
   201|
   202|
   203|def _search_rank(result: CatalogSearchResult, request: InstrumentSearchRequest) -> tuple[int, int, str]:
   204|    q = request.query.strip().upper()
   205|    currency = (request.currency or "").upper()
   206|    exchange = (request.exchange or "").upper()
   207|    result_currency = (result.trading_currency or result.instrument_currency or "").upper()
   208|    result_exchange = (result.exchange or result.provider_market or "").upper()
   209|    rank = 100
   210|    if result.isin and result.isin.upper() == q:
   211|        rank = 0
   212|    elif result.ticker and result.ticker.upper() == q and (not exchange or result_exchange == exchange) and (not currency or result_currency == currency):
   213|        rank = 1
   214|    elif result.ticker and result.ticker.upper() == q:
   215|        rank = 2
   216|    elif normalize_text(result.name) == normalize_text(request.query):
   217|        rank = 3
   218|    elif q in normalize_text(result.name).upper().split():
   219|        rank = 4
   220|    else:
   221|        rank = 8
   222|    penalty = 0
   223|    if result_exchange in _MAIN_EXCHANGES:
   224|        penalty -= 2
   225|    if _is_derivative_like(result):
   226|        penalty += 20
   227|    if result.confidence == "low":
   228|        penalty += 5
   229|    return (rank, penalty, result.name.lower())
   230|
   231|
   232|def normalize_text(value: str | None) -> str:
   233|    return " ".join(str(value or "").replace("-", " ").split()).lower()
   234|
   235|
   236|def _provider_warning_label(code: str) -> str:
   237|    lowered = code.lower()
   238|    if "api_key_missing" in lowered:
   239|        return "API-Key fehlt"
   240|    if "rate_limited" in lowered:
   241|        return "Rate Limit"
   242|    if "network" in lowered:
   243|        return "Provider nicht erreichbar"
   244|    if "endpoint_restricted" in lowered:
   245|        return "Endpoint eingeschränkt"
   246|    if "auth_failed" in lowered:
   247|        return "API-Key ungültig oder Provider nicht autorisiert"
   248|    if "provider_error" in lowered or "lookup_unavailable" in lowered:
   249|        return "Backend-/Provider-Fehler"
   250|    return code
   251|
   252|
   253|def _user_provider_source_hint(candidates: list[InstrumentSearchCandidate], warnings: list[str]) -> str | None:
   254|    if not candidates or not warnings:
   255|        return None
   256|    providers = sorted({p.strip() for c in candidates for p in (c.provider or "").replace("+", ",").split(",") if p.strip()})
   257|    source = "/".join(providers[:3]) if providers else "einem anderen Provider"
   258|    return f"Datenquelle teilweise eingeschränkt: Ein Provider lieferte keine Daten; Treffer stammt aus {source}."
   259|
   260|
   261|

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


   370|        if _confidence_label(existing) != "Manuell prüfen" and _confidence_label(result) != "Manuell prüfen":
   371|            existing.confidence = "high" if existing.confidence in {"medium", "high"} or result.confidence in {"medium", "high"} else existing.confidence
   372|        existing.exchange_name = existing.exchange_name or result.exchange_name
   373|        existing.mic = existing.mic or result.mic
   374|        existing.security_type = existing.security_type or result.security_type
   375|        existing.last_price = existing.last_price or result.last_price
   376|        existing.price_currency = existing.price_currency or result.price_currency
   377|        existing.price_date = existing.price_date or result.price_date
   378|        existing.country = existing.country or result.country
   379|    return list(merged.values())
   380|
   381|def provider_search_instruments(conn: Connection, request: InstrumentSearchRequest) -> ProviderSearchResponse:
   382|    if not request.query.strip():
   383|        raise HTTPException(status_code=422, detail="Search query required")
   384|    results, warnings = search_catalog_instruments(conn, request.query, request.asset_class, providers=_provider_search_providers())
   385|    filtered = _dedupe_provider_results([r for r in results if _passes_provider_filters(r, request)])
   386|    filtered.sort(key=lambda r: _search_rank(r, request))
   387|    candidates = [
   388|        InstrumentSearchCandidate(
   389|            candidate_id=r.catalog_entry_id or stable_id("candidate", r.source, r.name, r.isin or "", r.ticker or "", r.exchange or ""),
   390|            label=r.name,
   391|            asset_class=r.asset_class,
   392|            isin=r.isin or "",
   393|            ticker=r.ticker or "",
   394|            exchange=r.exchange or r.provider_market or "",
   395|            currency=_plausible_currency(r, request),
   396|            provider=r.provider or r.source,
   397|            provider_symbol=r.provider_symbol or r.ticker or "",
   398|            exchange_name=r.exchange_name or "",
   399|            mic=r.mic or r.provider_market or "",
   400|            country=r.country or "",
   401|            security_type=r.security_type or "",
   402|            last_price=r.last_price,
   403|            price_currency=r.price_currency,
   404|            price_date=r.price_date,
   405|            confidence_label=_confidence_label(r),
   406|            review_required=True,
   407|        )
   408|        for r in filtered[:12]
   409|    ]
   410|    ui_warnings: list[str] = []
   411|    if candidates:
   412|        hint = _user_provider_source_hint(candidates, warnings)
   413|        if hint:
   414|            ui_warnings.append(hint)
   415|    else:
   416|        ui_warnings.append("Keine verlässlichen Providerdaten gefunden.")
   417|    if len(candidates) > 1:
   418|        ui_warnings.append("Mehrdeutige Treffer")
   419|    return ProviderSearchResponse(candidates=candidates, warnings=sorted(set(ui_warnings)), selection_required=True)
   420|
   421|
   422|def _cache_candidate_price(conn: Connection, *, instrument_id: str, candidate_id: str | None) -> None:
   423|    if not candidate_id:
   424|        return
   425|    catalog = conn.execute("SELECT last_price, price_currency, trading_currency, instrument_currency, provider, source FROM instrument_catalog_entries WHERE catalog_entry_id=?", (candidate_id,)).fetchone()
   426|    if not catalog or not catalog["last_price"]:
   427|        return
   428|    currency = (catalog["price_currency"] or catalog["trading_currency"] or catalog["instrument_currency"] or "").upper()
   429|    if not currency:
   430|        return
   431|    price_date = _now()[:10]
   432|    price_id = stable_id("mprice", instrument_id, price_date, catalog["last_price"], currency, "candidate")
   433|    conn.execute(
   434|        """
   435|        INSERT OR IGNORE INTO market_prices(market_price_id, instrument_id, price_date, close, currency, provider, quality_status, created_at)
   436|        VALUES (?, ?, ?, ?, ?, ?, 'ok', ?)
   437|        """,
   438|        (price_id, instrument_id, price_date, str(catalog["last_price"]), currency, catalog["provider"] or catalog["source"] or "provider_search", _now()),
   439|    )
   440|
   441|
   442|def preview_cash(conn: Connection, request: CashPreviewRequest) -> PreviewResponse:
   443|    _decimal(request.amount, "amount")
   444|    account = conn.execute("SELECT account_id FROM accounts WHERE account_id=?", (request.account_id,)).fetchone()
   445|    if not account:
   446|        raise HTTPException(status_code=404, detail="Account not found")
   447|    fx_status = "not_needed" if request.currency.upper() == "CHF" else "missing"
   448|    warnings = [] if fx_status == "not_needed" else ["FX fehlt: Speichern bleibt möglich, CHF-Wert wird leer markiert."]
   449|    amount_chf = decimal_text(request.amount, 2) if fx_status == "not_needed" else None
   450|    return PreviewResponse(preview_id=_uuid("preview"), summary=f"{request.transaction_type} {request.currency.upper()} {request.amount}", amount_chf=amount_chf, fx_status=fx_status, warnings=warnings)
   451|
   452|
   453|def confirm_cash(conn: Connection, request: CashConfirmRequest) -> ConfirmResponse:
   454|    if not request.confirm:
   455|        raise HTTPException(status_code=400, detail="Explicit confirm required")
   456|    preview = preview_cash(conn, request)
   457|    balance_id = _uuid("tx")
   458|    ts = _now()
   459|    fx_rate = "1" if request.currency.upper() == "CHF" else None
   460|    quality = "ok" if request.currency.upper() == "CHF" else "missing_fx"
   461|    amount = str(_decimal(request.amount, "amount"))
   462|    transaction_type = _normalise_transaction_type(request.transaction_type, "cash")
   463|    conn.execute(
   464|        """
   465|        INSERT INTO transactions(transaction_id, transaction_type, account_id, trade_date, gross_amount_original, net_amount_original, currency_original, fx_rate_to_chf, fx_status, gross_amount_chf, net_amount_chf, source_type, is_confirmed, quality_status, notes, created_at)
   466|        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vue_manual_cash', 1, ?, ?, ?)
   467|        """,
   468|        (balance_id, transaction_type, request.account_id, request.booking_date, amount, amount, request.currency.upper(), fx_rate, "ok" if fx_rate else "missing", preview.amount_chf, preview.amount_chf, quality, request.note, ts),
   469|    )
   470|    conn.execute(
   471|        """
   472|        INSERT OR IGNORE INTO cash_balances(cash_balance_id, account_id, balance_date, currency, amount_original, fx_rate_to_chf, amount_chf, source_type, quality_status, notes, created_at)
   473|        VALUES (?, ?, ?, ?, ?, ?, ?, 'vue_manual_cash', ?, ?, ?)
   474|        """,
   475|        (_uuid("cash"), request.account_id, request.booking_date, request.currency.upper(), amount, fx_rate, preview.amount_chf, quality, request.note, ts),
   476|    )
   477|    audit_id = _audit(conn, action="cash_confirm", entity_type="cash_position", entity_id=f"{request.account_id}:{request.currency.upper()}", payload=request.model_dump(), note=request.note, quality_status=quality)
   478|    conn.commit()
   479|    return ConfirmResponse(status="confirmed", entity_id=f"{request.account_id}:{request.currency.upper()}", audit_id=audit_id, message="Cash gespeichert")
   480|
   481|
   482|def preview_position(conn: Connection, request: PositionPreviewRequest) -> PreviewResponse:
   483|    _decimal(request.quantity, "quantity")
   484|    account = conn.execute("SELECT account_id FROM accounts WHERE account_id=?", (request.account_id,)).fetchone()
   485|    if not account:
   486|        raise HTTPException(status_code=404, detail="Account not found")
   487|    inst = _instrument_preview_row(conn, request)
   488|    if not inst:
   489|        raise HTTPException(status_code=404, detail="Instrument not found")
   490|    currency = request.currency.upper()
   491|    fx_status, fx_rate, _fx_source, fx_warning = _fx_for_explicit_save(conn, currency=currency, trade_date=request.trade_date)
   492|    warnings = [fx_warning] if fx_warning else []
   493|    amount_chf = _chf_amount(request.cost_basis_original, Decimal(fx_rate) if fx_rate else None)
   494|    return PreviewResponse(preview_id=_uuid("preview"), asset_class=request.asset_class.lower(), summary=f"{inst['name']} · {request.quantity} · {currency}", amount_chf=amount_chf, fx_status=fx_status, warnings=warnings)
   495|
   496|
   497|def confirm_position(conn: Connection, request: PositionConfirmRequest) -> ConfirmResponse:
   498|    if not request.confirm:
   499|        raise HTTPException(status_code=400, detail="Explicit confirm required")
   500|    preview_position(conn, request)
   501|    instrument_id = _ensure_instrument_from_request(conn, request)
   502|    if instrument_id is None:
   503|        raise HTTPException(status_code=404, detail="Instrument not found")
   504|    _cache_candidate_price(conn, instrument_id=instrument_id, candidate_id=request.candidate_id)
   505|    tx_id = _uuid("tx")
   506|    ts = _now()
   507|    cost = request.cost_basis_original or "0"
   508|    fx_status, fx_rate, fx_source, _fx_warning = _fx_for_explicit_save(conn, currency=request.currency, trade_date=request.trade_date)
   509|    gross_chf = _chf_amount(cost, Decimal(fx_rate) if fx_rate else None)
   510|    quality_parts = []
   511|    if not request.cost_basis_original:
   512|        quality_parts.append("cost_basis_missing")
   513|    if fx_status == "missing":
   514|        quality_parts.append("missing_fx")
   515|    quality = ";".join(quality_parts) or "ok"
   516|    transaction_type = _normalise_transaction_type(request.transaction_type)
   517|    conn.execute(
   518|        """
   519|        INSERT INTO transactions(transaction_id, transaction_type, account_id, instrument_id, trade_date, quantity, gross_amount_original, net_amount_original, currency_original, fx_rate_to_chf, fx_source, fx_status, gross_amount_chf, net_amount_chf, source_type, is_confirmed, quality_status, notes, created_at)
   520|        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vue_manual_position', 1, ?, ?, ?)
   521|        """,
   522|        (tx_id, transaction_type, request.account_id, instrument_id, request.trade_date, str(_decimal(request.quantity, "quantity")), cost, cost, request.currency.upper(), fx_rate, fx_source, fx_status, gross_chf, gross_chf, quality, request.note, ts),
   523|    )
   524|    audit_id = _audit(conn, action="position_confirm", entity_type="position", entity_id=f"{request.account_id}:{instrument_id}", payload=request.model_dump(), note=request.note, quality_status=quality)
   525|    conn.commit()
   526|    return ConfirmResponse(status="confirmed", entity_id=tx_id, audit_id=audit_id, message="Position gespeichert")
   527|
   528|
   529|def get_cash_position_detail(conn: Connection, position_id: str) -> CashPositionDetail:
   530|    position = next((p for p in get_cash_summary(conn).positions if p.id == position_id), None)
   531|    account_id, currency = position_id.split(":", 1)
   532|    if not position:
   533|        row = conn.execute(
   534|            """
   535|            SELECT p.name AS platform, a.account_name, cb.currency, cb.amount_original, cb.amount_chf, cb.quality_status, MAX(cb.created_at) AS last_change
   536|            FROM cash_balances cb JOIN accounts a ON a.account_id = cb.account_id JOIN platforms p ON p.platform_id = a.platform_id
   537|            WHERE cb.account_id=? AND cb.currency=?
   538|            GROUP BY cb.account_id, cb.currency
   539|            """,
   540|            (account_id, currency),
   541|        ).fetchone()
   542|        if not row:
   543|            raise HTTPException(status_code=404, detail="Cash position not found")
   544|        payload = {"id": position_id, "platform": row["platform"], "account_label": row["account_name"], "currency": row["currency"], "amount": optional_decimal_text(row["amount_original"]) or "0", "amount_chf": optional_decimal_text(row["amount_chf"], 2), "status": row["quality_status"] or "ok"}
   545|        last_change = row["last_change"]
   546|    else:
   547|        payload = position.model_dump()
   548|        row = conn.execute("SELECT MAX(created_at) AS last_change FROM cash_balances WHERE account_id=? AND currency=?", (account_id, currency)).fetchone()
   549|        last_change = row["last_change"] if row else None
   550|

Use offset=550 to continue reading (showing 370-549 of 999 lines)


Explanation:
**Processing schemas for cash confirmation**

I need to instantiate Pydantic schemas and consider using a dict for this. My function, confirm_cash, is expecting a CashConfirmRequest, so I should handle that import as well. I’ll run the script to check if I need a schema migration, especially since we're already on v26. I'll also look into candidate seed functions and account selections, making sure to handle budget categories appropriately. If there are no categories, I'll abort and ensure the output remains concise without amounts.

from hermes_tools import terminal
repo='/home/agent/.hermes/repos/FinanceManager'
script=r'''
from pathlib import Path
import shutil, sqlite3, json, datetime
from jarvis_finance.storage.database import connect
from jarvis_finance.services.budget_imports import (
    seed_credit_card_candidates_from_rows, update_transaction_candidate_category,
    confirm_transaction_candidate, ignore_transaction_candidate, reopen_transaction_candidate,
    create_merchant, create_merchant_alias, apply_merchant_to_open_candidates,
    confirm_candidate_split, preview_transaction_candidate, create_budget_rule,
    test_budget_rule, apply_budget_rules_to_open_candidates,
)
from jarvis_finance.services.manual_entry_service import preview_cash, confirm_cash
from jarvis_finance.api.schemas.positions import CashConfirmRequest, CashPreviewRequest
runtime=Path('/home/agent/jarvis_runtime/finance-system')
source=runtime/'data/finance.sqlite3'
ts=datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
uat_dir=runtime/'uat-tests'/f'release-v03-{ts}'
uat_dir.mkdir(parents=True, exist_ok=True)
uat_db=uat_dir/'finance.sqlite3'
shutil.copy2(source, uat_db)
conn=connect(uat_db)
# verify schema
schema_version=conn.execute('SELECT MAX(version) FROM schema_migrations').fetchone()[0]
acct=conn.execute("SELECT account_id FROM accounts WHERE is_active=1 ORDER BY created_at LIMIT 1").fetchone()
cat_rows=conn.execute("SELECT category_id FROM budget_categories WHERE is_active=1 AND category_type='expense' ORDER BY sort_order, name LIMIT 2").fetchall()
if not acct or len(cat_rows)<1:
    raise SystemExit('uat_prereq_missing_account_or_category')
account_id=acct['account_id']; cat1=cat_rows[0]['category_id']; cat2=(cat_rows[1]['category_id'] if len(cat_rows)>1 else cat1)
results={}
# 1 confirm safe synthetic candidate on test copy
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-01','Beschreibung':'UAT SAFE CONFIRM MERCHANT','Betrag':'1.11'}],source_file_label='UAT_RELEASE_CONFIRM', migros_covered=False)
cid=conn.execute("SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE source_file_label='UAT_RELEASE_CONFIRM' ORDER BY created_at DESC LIMIT 1").fetchone()['transaction_candidate_id']
update_transaction_candidate_category(conn,cid,cat1,'UAT category set')
confirm_res=confirm_transaction_candidate(conn,cid,{'account_id':account_id,'category_id':cat1,'budget_year':'2026'})
results['budget_candidate_confirm']={'ok':confirm_res['status']=='confirmed','audit':bool(confirm_res.get('audit_id'))}
# 2 ignore and reopen
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-02','Beschreibung':'UAT IGNORE REOPEN MERCHANT','Betrag':'1.12'}],source_file_label='UAT_RELEASE_IGNORE', migros_covered=False)
cid2=conn.execute("SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE source_file_label='UAT_RELEASE_IGNORE' ORDER BY created_at DESC LIMIT 1").fetchone()['transaction_candidate_id']
ign=ignore_transaction_candidate(conn,cid2,'UAT ignore')
reo=reopen_transaction_candidate(conn,cid2,'UAT reopen')
results['ignore_reopen']={'ok':ign['status']=='ignored' and reo['status'] in {'needs_review','pending'},'audit':bool(ign.get('audit_id')) and bool(reo.get('audit_id'))}
# 3 Merchant/Alias apply
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-03','Beschreibung':'UATMERCHANT SHOP SAMPLE','Betrag':'1.13'}],source_file_label='UAT_RELEASE_MERCHANT', migros_covered=False)
merch=create_merchant(conn, {'display_name':'UAT Merchant','default_category_id':cat1,'notes':'UAT test runtime only'})
create_merchant_alias(conn, merch['merchant_id'], {'pattern':'UATMERCHANT','match_type':'contains','priority':1})
app=apply_merchant_to_open_candidates(conn, merch['merchant_id'])
results['merchant_alias_apply']={'ok':app['updated_count']>=1,'audit':bool(app.get('audit_id'))}
# 4 Rule test and apply (suggestions only)
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-04','Beschreibung':'UATRULE SHOP SAMPLE','Betrag':'1.14'}],source_file_label='UAT_RELEASE_RULE', migros_covered=False)
rule=create_budget_rule(conn, {'merchant_contains':'UATRULE','category_id':cat1,'target_status':'review','priority':1,'confidence':'0.88','notes':'UAT test runtime only'})
test=test_budget_rule(conn, rule['rule_id'])
apply=apply_budget_rules_to_open_candidates(conn, rule['rule_id'])
results['rule_test_apply']={'ok':test['match_count']>=1 and apply['productive_transaction_count']==0,'audit':bool(apply.get('audit_id'))}
# 5 Split confirm on test copy
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-05','Beschreibung':'UAT SPLIT STORE','Betrag':'1.00'}],source_file_label='UAT_RELEASE_SPLIT', migros_covered=False)
cid3=conn.execute("SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE source_file_label='UAT_RELEASE_SPLIT' ORDER BY created_at DESC LIMIT 1").fetchone()['transaction_candidate_id']
# preview expected to fail before category/account only if no category; then split confirm provides categories
split=confirm_candidate_split(conn,cid3,{'account_id':account_id,'splits':[{'category_id':cat1,'amount_original':'0.60','note':'UAT split A'},{'category_id':cat2,'amount_original':'0.40','note':'UAT split B'}]})
results['split_confirm_test_runtime']={'ok':split['status']=='confirmed' and split['created_transactions']==2,'audit':bool(split.get('audit_id'))}
# 6 Cash correction on test copy
prev=preview_cash(conn, CashPreviewRequest(account_id=account_id, transaction_type='manual_cash_correction', amount='1.00', currency='CHF', booking_date='2026-02-06', note='UAT cash correction test runtime only'))
cash=confirm_cash(conn, CashConfirmRequest(account_id=account_id, transaction_type='manual_cash_correction', amount='1.00', currency='CHF', booking_date='2026-02-06', note='UAT cash correction test runtime only', confirm=True))
results['cash_correction_test_runtime']={'ok':cash.status=='confirmed','audit':bool(cash.audit_id)}
# central tables and productive unchanged stat
prod_stat=source.stat(); conn.close(); prod_stat2=source.stat()
summary={
 'uat_db':str(uat_db),
 'schema_version':schema_version,
 'test_runtime_outside_repo': str(uat_db).startswith(str(runtime)),
 'productive_runtime_unchanged_size_mtime': prod_stat.st_size==prod_stat2.st_size and prod_stat.st_mtime_ns==prod_stat2.st_mtime_ns,
 'results':results,
 'all_ok': all(v['ok'] and v['audit'] for v in results.values())
}
print(json.dumps(summary,indent=2))
'''
out=terminal("PYTHONPATH=src python - <<'PY'\n"+script+"\nPY", workdir=repo, timeout=300)
print(out['output'])
print('exit', out['exit_code'])


Output:
<stdin>:16: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
Traceback (most recent call last):
  File "<stdin>", line 34, in <module>
  File "/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py", line 822, in confirm_transaction_candidate
    result = confirm_budget_transaction(conn, preview["payload"])
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_transactions.py", line 86, in confirm_budget_transaction
    tx = _normalise_tx_payload(conn, payload)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_transactions.py", line 31, in _normalise_tx_payload
    raise HTTPException(status_code=422, detail="active budget account required")
fastapi.exceptions.HTTPException: 422: active budget account required
exit 1


UAT-Testkopie hat ersten Versuch gestoppt: Budget-Confirm braucht ein aktives **Budget-Konto**, nicht ein allgemeines Portfolio-Account. Ich passe den Testlauf auf Budget-Konto an und starte mit frischer Testkopie neu.


     1|from __future__ import annotations
     2|
     3|from decimal import Decimal
     4|from sqlite3 import Connection
     5|
     6|from fastapi import HTTPException
     7|
     8|from jarvis_finance.audit.log import record_audit_event
     9|from jarvis_finance.services.budget_common import new_id, now, require_decimal_text, row_to_dict, validate_currency
    10|
    11|TX_TYPES = {"income", "expense", "transfer", "refund", "fee", "adjustment", "reversal"}
    12|
    13|
    14|def _fx_fields(conn: Connection, currency: str, amount_text: str) -> tuple[str, str | None, str | None]:
    15|    cur = validate_currency(currency)
    16|    if cur == "CHF":
    17|        return "not_needed", "1", amount_text
    18|    row = conn.execute(
    19|        "SELECT rate FROM fx_rates WHERE base_currency=? AND quote_currency='CHF' ORDER BY rate_date DESC LIMIT 1",
    20|        (cur,),
    21|    ).fetchone()
    22|    if not row or row["rate"] in (None, ""):
    23|        return "missing", None, None
    24|    rate = Decimal(str(row["rate"]))
    25|    return "ok", format(rate, "f"), format(Decimal(amount_text) * rate, "f")
    26|
    27|
    28|def _normalise_tx_payload(conn: Connection, payload: dict, *, force_type: str | None = None) -> dict:
    29|    account_id = str(payload.get("account_id") or "").strip()
    30|    if not conn.execute("SELECT 1 FROM budget_accounts WHERE budget_account_id=? AND is_active=1", (account_id,)).fetchone():
    31|        raise HTTPException(status_code=422, detail="active budget account required")
    32|    tx_type = force_type or str(payload.get("transaction_type") or "expense").strip().lower()
    33|    if tx_type not in TX_TYPES:
    34|        raise HTTPException(status_code=422, detail="invalid transaction_type")
    35|    description = str(payload.get("description") or "").strip()
    36|    if not description:
    37|        raise HTTPException(status_code=422, detail="description is required")
    38|    amount = require_decimal_text(str(payload.get("amount_original") or ""), "amount_original")
    39|    currency = validate_currency(payload.get("currency_original") or "CHF")
    40|    fx_status, fx_rate, amount_chf = _fx_fields(conn, currency, amount)
    41|    if payload.get("fx_rate_to_chf"):
    42|        fx_rate = require_decimal_text(str(payload["fx_rate_to_chf"]), "fx_rate_to_chf")
    43|        amount_chf = format(Decimal(amount) * Decimal(fx_rate), "f")
    44|        fx_status = "not_needed" if currency == "CHF" else "manual_override"
    45|    return {
    46|        "budget_transaction_id": payload.get("budget_transaction_id") or new_id("btx"),
    47|        "account_id": account_id,
    48|        "transaction_type": tx_type,
    49|        "transaction_date": str(payload.get("transaction_date") or now()[:10]),
    50|        "booking_date": payload.get("booking_date") or None,
    51|        "description": description,
    52|        "payee": payload.get("payee") or None,
    53|        "merchant_id": payload.get("merchant_id") or None,
    54|        "amount_original": amount,
    55|        "currency_original": currency,
    56|        "fx_rate_to_chf": fx_rate,
    57|        "amount_chf": amount_chf,
    58|        "fx_status": fx_status,
    59|        "category_id": payload.get("category_id") or None,
    60|        "source_type": payload.get("source_type") or "manual",
    61|        "source_candidate_id": payload.get("source_candidate_id") or None,
    62|        "notes": payload.get("notes") or None,
    63|        "tag_names": payload.get("tag_names") or [],
    64|        "reversal_of_transaction_id": payload.get("reversal_of_transaction_id") or None,
    65|    }
    66|
    67|
    68|def create_budget_transaction_preview(conn: Connection, payload: dict) -> dict:
    69|    tx = _normalise_tx_payload(conn, payload)
    70|    warnings = []
    71|    if tx["fx_status"] == "missing":
    72|        warnings.append("FX fehlt: CHF-Wert bleibt leer, Buchung kann bewusst unvollständig gespeichert werden.")
    73|    if not tx["category_id"] and tx["transaction_type"] in {"income", "expense", "refund", "fee"}:
    74|        warnings.append("Kategorie fehlt: Buchung bleibt unkategorisiert.")
    75|    return {"preview_id": new_id("preview"), "summary": f"{tx['transaction_type']} · {tx['currency_original']} {tx['amount_original']} · {tx['description']}", "fx_status": tx["fx_status"], "warnings": warnings, "payload": tx}
    76|
    77|
    78|def _insert_tags(conn: Connection, tx_id: str, tag_names: list[str]) -> None:
    79|    for name in tag_names:
    80|        row = conn.execute("SELECT tag_id FROM budget_tags WHERE name=?", (name,)).fetchone()
    81|        if row:
    82|            conn.execute("INSERT OR IGNORE INTO budget_transaction_tags(budget_transaction_id, tag_id) VALUES (?, ?)", (tx_id, row["tag_id"]))
    83|
    84|
    85|def confirm_budget_transaction(conn: Connection, payload: dict) -> dict:
    86|    tx = _normalise_tx_payload(conn, payload)
    87|    if tx["category_id"] and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=?", (tx["category_id"],)).fetchone():
    88|        raise HTTPException(status_code=422, detail="category not found")
    89|    ts = now()
    90|    conn.execute(
    91|        """
    92|        INSERT INTO budget_transactions(
    93|            budget_transaction_id, account_id, transaction_type, transaction_date, booking_date,
    94|            description, payee, merchant_id, amount_original, currency_original, fx_rate_to_chf,
    95|            amount_chf, fx_status, category_id, status, source_type, notes, created_at, updated_at,
    96|            reversal_of_transaction_id, source_candidate_id
    97|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'confirmed', ?, ?, ?, ?, ?, ?)
    98|        """,
    99|        (tx["budget_transaction_id"], tx["account_id"], tx["transaction_type"], tx["transaction_date"], tx["booking_date"], tx["description"], tx["payee"], tx["merchant_id"], tx["amount_original"], tx["currency_original"], tx["fx_rate_to_chf"], tx["amount_chf"], tx["fx_status"], tx["category_id"], tx["source_type"], tx["notes"], ts, ts, tx["reversal_of_transaction_id"], tx["source_candidate_id"]),
   100|    )
   101|    _insert_tags(conn, tx["budget_transaction_id"], tx["tag_names"])
   102|    audit_id = record_audit_event(conn, source="vue_dashboard", action="confirm", entity_type="budget_transaction", entity_id=tx["budget_transaction_id"], new_values=tx, user_text_note=tx.get("notes"), created_by="user")
   103|    conn.commit()
   104|    return {"status": "confirmed", "entity_id": tx["budget_transaction_id"], "audit_id": audit_id, "message": "Budgetbuchung gespeichert"}
   105|
   106|
   107|def reverse_budget_transaction_preview(conn: Connection, transaction_id: str, payload: dict | None = None) -> dict:
   108|    row = conn.execute("SELECT * FROM budget_transactions WHERE budget_transaction_id=? AND status='confirmed'", (transaction_id,)).fetchone()
   109|    if not row:
   110|        raise HTTPException(status_code=404, detail="confirmed transaction not found")
   111|    reason = (payload or {}).get("reason") or "Reversal"
   112|    reversal = row_to_dict(row) | {"budget_transaction_id": new_id("btx"), "transaction_type": "reversal", "description": f"Reversal: {row['description']}", "notes": reason, "reversal_of_transaction_id": transaction_id, "tag_names": []}
   113|    return {"preview_id": new_id("preview"), "summary": f"Reversal · {row['description']}", "warnings": [], "payload": reversal}
   114|
   115|
   116|def confirm_reverse_budget_transaction(conn: Connection, transaction_id: str, payload: dict) -> dict:
   117|    result = confirm_budget_transaction(conn, payload | {"reversal_of_transaction_id": transaction_id, "transaction_type": "reversal"})
   118|    ts = now()
   119|    conn.execute("UPDATE budget_transactions SET status='reversed', updated_at=? WHERE budget_transaction_id=?", (ts, transaction_id))
   120|    record_audit_event(conn, source="vue_dashboard", action="reverse", entity_type="budget_transaction", entity_id=transaction_id, new_values={"status": "reversed", "reversal_transaction_id": result["entity_id"]}, user_text_note=payload.get("notes"), created_by="user")
   121|

Use offset=121 to continue reading (showing 1-120 of 260 lines)


Explanation:
**Checking account details**

I need to rerun the process using the budget account ID and the cash general account ID. It's essential to ensure that there are no duplicate candidates in the copy. I want to make sure everything is fresh from production without involving UAT. This plan sounds good to me, and I’ll proceed accordingly!

from hermes_tools import terminal
repo='/home/agent/.hermes/repos/FinanceManager'
script=r'''
from pathlib import Path
import shutil, json, datetime
from jarvis_finance.storage.database import connect
from jarvis_finance.services.budget_imports import (
    seed_credit_card_candidates_from_rows, update_transaction_candidate_category,
    confirm_transaction_candidate, ignore_transaction_candidate, reopen_transaction_candidate,
    create_merchant, create_merchant_alias, apply_merchant_to_open_candidates,
    confirm_candidate_split, create_budget_rule, test_budget_rule, apply_budget_rules_to_open_candidates,
)
from jarvis_finance.services.manual_entry_service import preview_cash, confirm_cash
from jarvis_finance.api.schemas.positions import CashConfirmRequest, CashPreviewRequest
runtime=Path('/home/agent/jarvis_runtime/finance-system')
source=runtime/'data/finance.sqlite3'
ts=datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
uat_dir=runtime/'uat-tests'/f'release-v03-{ts}'
uat_dir.mkdir(parents=True, exist_ok=True)
uat_db=uat_dir/'finance.sqlite3'
shutil.copy2(source, uat_db)
prod_before=source.stat()
conn=connect(uat_db)
schema_version=conn.execute('SELECT MAX(version) FROM schema_migrations').fetchone()[0]
budget_acct=conn.execute("SELECT budget_account_id FROM budget_accounts WHERE is_active=1 ORDER BY created_at LIMIT 1").fetchone()
cash_acct=conn.execute("SELECT account_id FROM accounts WHERE is_active=1 ORDER BY created_at LIMIT 1").fetchone()
cat_rows=conn.execute("SELECT category_id FROM budget_categories WHERE is_active=1 AND category_type='expense' ORDER BY sort_order, name LIMIT 2").fetchall()
if not budget_acct or not cash_acct or len(cat_rows)<1:
    raise SystemExit('uat_prereq_missing_account_or_category')
budget_account_id=budget_acct['budget_account_id']; cash_account_id=cash_acct['account_id']; cat1=cat_rows[0]['category_id']; cat2=(cat_rows[1]['category_id'] if len(cat_rows)>1 else cat1)
results={}
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-01','Beschreibung':'UAT SAFE CONFIRM MERCHANT','Betrag':'1.11'}],source_file_label='UAT_RELEASE_CONFIRM', migros_covered=False)
cid=conn.execute("SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE source_file_label='UAT_RELEASE_CONFIRM' ORDER BY created_at DESC LIMIT 1").fetchone()['transaction_candidate_id']
update_transaction_candidate_category(conn,cid,cat1,'UAT category set')
confirm_res=confirm_transaction_candidate(conn,cid,{'account_id':budget_account_id,'category_id':cat1,'budget_year':'2026'})
results['budget_candidate_confirm']={'ok':confirm_res['status']=='confirmed','audit':bool(confirm_res.get('audit_id'))}
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-02','Beschreibung':'UAT IGNORE REOPEN MERCHANT','Betrag':'1.12'}],source_file_label='UAT_RELEASE_IGNORE', migros_covered=False)
cid2=conn.execute("SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE source_file_label='UAT_RELEASE_IGNORE' ORDER BY created_at DESC LIMIT 1").fetchone()['transaction_candidate_id']
ign=ignore_transaction_candidate(conn,cid2,'UAT ignore')
reo=reopen_transaction_candidate(conn,cid2,'UAT reopen')
results['ignore_reopen']={'ok':ign['status']=='ignored' and reo['status'] in {'needs_review','pending'},'audit':bool(ign.get('audit_id')) and bool(reo.get('audit_id'))}
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-03','Beschreibung':'UATMERCHANT SHOP SAMPLE','Betrag':'1.13'}],source_file_label='UAT_RELEASE_MERCHANT', migros_covered=False)
merch=create_merchant(conn, {'display_name':'UAT Merchant','default_category_id':cat1,'notes':'UAT test runtime only'})
create_merchant_alias(conn, merch['merchant_id'], {'pattern':'UATMERCHANT','match_type':'contains','priority':1})
app=apply_merchant_to_open_candidates(conn, merch['merchant_id'])
results['merchant_alias_apply']={'ok':app['updated_count']>=1,'audit':bool(app.get('audit_id'))}
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-04','Beschreibung':'UATRULE SHOP SAMPLE','Betrag':'1.14'}],source_file_label='UAT_RELEASE_RULE', migros_covered=False)
rule=create_budget_rule(conn, {'merchant_contains':'UATRULE','category_id':cat1,'target_status':'review','priority':1,'confidence':'0.88','notes':'UAT test runtime only'})
test=test_budget_rule(conn, rule['rule_id'])
apply=apply_budget_rules_to_open_candidates(conn, rule['rule_id'])
results['rule_test_apply']={'ok':test['match_count']>=1 and apply['productive_transaction_count']==0,'audit':bool(apply.get('audit_id'))}
seed_credit_card_candidates_from_rows(conn,[{'Datum':'2026-02-05','Beschreibung':'UAT SPLIT STORE','Betrag':'1.00'}],source_file_label='UAT_RELEASE_SPLIT', migros_covered=False)
cid3=conn.execute("SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE source_file_label='UAT_RELEASE_SPLIT' ORDER BY created_at DESC LIMIT 1").fetchone()['transaction_candidate_id']
split=confirm_candidate_split(conn,cid3,{'account_id':budget_account_id,'splits':[{'category_id':cat1,'amount_original':'0.60','note':'UAT split A'},{'category_id':cat2,'amount_original':'0.40','note':'UAT split B'}]})
results['split_confirm_test_runtime']={'ok':split['status']=='confirmed' and split['created_transactions']==2,'audit':bool(split.get('audit_id'))}
prev=preview_cash(conn, CashPreviewRequest(account_id=cash_account_id, transaction_type='manual_cash_correction', amount='1.00', currency='CHF', booking_date='2026-02-06', note='UAT cash correction test runtime only'))
cash=confirm_cash(conn, CashConfirmRequest(account_id=cash_account_id, transaction_type='manual_cash_correction', amount='1.00', currency='CHF', booking_date='2026-02-06', note='UAT cash correction test runtime only', confirm=True))
results['cash_correction_test_runtime']={'ok':cash.status=='confirmed','audit':bool(cash.audit_id)}
conn.close()
prod_after=source.stat()
summary={
 'uat_db':str(uat_db),
 'schema_version':schema_version,
 'test_runtime_outside_repo': True,
 'productive_runtime_unchanged_size_mtime': prod_before.st_size==prod_after.st_size and prod_before.st_mtime_ns==prod_after.st_mtime_ns,
 'results':results,
 'all_ok': all(v['ok'] and v['audit'] for v in results.values())
}
print(json.dumps(summary,indent=2))
'''
out=terminal("PYTHONPATH=src python - <<'PY'\n"+script+"\nPY", workdir=repo, timeout=300)
print(out['output'])
print('exit', out['exit_code'])