   300|        return seed_transaction_candidates_from_rows(conn, list(reader), source_file_label=source_file_label, source_type=source_type)
   301|    return seed_transaction_candidates_from_rows(conn, list(csv.reader(text.splitlines(), dialect=dialect)), source_file_label=source_file_label, source_type=source_type)
   302|
   303|
   304|def seed_migros_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, auto_category_id: str | None = None, threshold_chf: str = "50.00", candidate_source_type: str = "migros_receipt") -> dict[str, int]:
   305|    _threshold = Decimal(threshold_chf)
   306|    if not auto_category_id:
   307|        auto_category_id, auto_category_name = _category_id_name(conn, "Essen & Haushalt")
   308|    else:
   309|        auto_category_name = _category_name(conn, auto_category_id)
   310|    grouped: dict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
   311|    ignored = 0
   312|    for idx, raw in enumerate(rows, start=1):
   313|        low = {_normalise(k): v for k, v in raw.items()}
   314|        tx_date = _date_text(low.get("datum") or low.get("date"))
   315|        time = str(low.get("zeit") or low.get("time") or "").strip()
   316|        store = str(low.get("filiale") or low.get("store") or "Migros").strip()
   317|        register = str(low.get("kasse") or low.get("kassennummer") or low.get("register") or "").strip()
   318|        txn = str(low.get("transaktion") or low.get("transaktionsnummer") or low.get("bon") or low.get("bon-id") or "").strip()
   319|        amount = _decimal_text(low.get("umsatz") or low.get("betrag") or low.get("amount"))
   320|        if not tx_date or not amount:
   321|            ignored += 1
   322|            continue
   323|        key = _fingerprint([tx_date, time, store, register, txn])
   324|        grouped[key].append((idx, raw))
   325|    created = auto = review = line_count = 0
   326|    for key, items in grouped.items():
   327|        first_low = {_normalise(k): v for k, v in items[0][1].items()}
   328|        tx_date = _date_text(first_low.get("datum") or first_low.get("date"))
   329|        time = str(first_low.get("zeit") or first_low.get("time") or "").strip()
   330|        store = str(first_low.get("filiale") or first_low.get("store") or "Migros").strip() or "Migros"
   331|        register = str(first_low.get("kasse") or first_low.get("kassennummer") or first_low.get("register") or "").strip()
   332|        txn = str(first_low.get("transaktion") or first_low.get("transaktionsnummer") or first_low.get("bon") or first_low.get("bon-id") or "").strip()
   333|        total = sum(Decimal(_decimal_text(({_normalise(k): v for k, v in raw.items()}).get("umsatz") or ({_normalise(k): v for k, v in raw.items()}).get("betrag") or ({_normalise(k): v for k, v in raw.items()}).get("amount")) or "0") for _, raw in items)
   334|        amount = format(total, "f")
   335|        needs_review = auto_category_id is None
   336|        status = "needs_review" if needs_review else "auto_categorized"
   337|        classification = "migros_review_missing_category" if needs_review else "migros_receipt_food_household"
   338|        review_reason = "migros_receipt_missing_food_household_category" if needs_review else "migros_receipt_auto_food_household"
   339|        row_ref = f"BON:{tx_date}:{time}:{store}:{register}:{txn}"[:180]
   340|        ts = now()
   341|        candidate_id = "btxcand_" + _candidate_key(source_file_label, row_ref, tx_date, amount, "Migros Bon")
   342|        inserted = _insert_candidate(conn, {
   343|            "transaction_candidate_id": candidate_id,
   344|            "source_file_label": source_file_label,
   345|            "source_row_or_range": row_ref,
   346|            "source_type": candidate_source_type,
   347|            "transaction_date": tx_date,
   348|            "description": f"Migros Bon {store}"[:300],
   349|            "merchant": store,
   350|            "amount_original": amount,
   351|            "currency_original": "CHF",
   352|            "proposed_category_id": None if needs_review else auto_category_id,
   353|            "proposed_category_name": None if needs_review else auto_category_name,
   354|            "confidence": "0.90" if not needs_review else "0.60",
   355|            "requires_review": needs_review,
   356|            "status": status,
   357|            "notes": json.dumps({"rule": "migros_receipt_food_household", "legacy_threshold_chf": threshold_chf, "line_items": len(items), "article_rows_are_detail_only": True}, ensure_ascii=False),
   358|            "classification": classification,
   359|            "review_reason": review_reason,
   360|            "rule_id": "migros_receipt_food_household" if not needs_review else "migros_receipt_missing_category",
   361|            "rule_name": "Migros Bon = Essen & Haushalt" if not needs_review else "Migros Bon Kategorie fehlt",
   362|            "source_priority": 90,
   363|            "receipt_key": key,
   364|            "raw_fingerprint": key,
   365|            "created_at": ts,
   366|            "updated_at": ts,
   367|        })
   368|        if inserted:
   369|            created += 1
   370|            auto += 0 if needs_review else 1
   371|            review += 1 if needs_review else 0
   372|            for idx, raw in items:
   373|                low = {_normalise(k): v for k, v in raw.items()}
   374|                item_amount = _decimal_text(low.get("umsatz") or low.get("betrag") or low.get("amount"))
   375|                conn.execute(
   376|                    """
   377|                    INSERT OR IGNORE INTO budget_import_line_items(
   378|                        line_item_id, transaction_candidate_id, source_file_label, receipt_key,
   379|                        source_row_or_range, item_name, quantity, is_promotion, amount_original,
   380|                        currency_original, raw_fingerprint, created_at
   381|                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, ?)
   382|                    """,
   383|                    ("bline_" + _fingerprint([source_file_label, key, idx]), candidate_id, source_file_label, key, f"R{idx}", str(low.get("artikel") or low.get("article") or "").strip(), str(low.get("menge") or low.get("quantity") or "").strip() or None, 1 if _normalise(low.get("aktion") or "") in {"1", "true", "ja", "yes"} else 0, item_amount, _fingerprint([source_file_label, idx, raw]), ts),
   384|                )
   385|                line_count += 1
   386|    conn.commit()
   387|    return {"receipt_candidate_count": created, "candidate_count": created, "line_item_count": line_count, "auto_assigned_count": auto, "review_required_count": review, "ignored_count": ignored}
   388|
   389|
   390|def preview_migros_receipts_2026(conn: Connection, *, year: str = "2026") -> dict[str, Any]:
   391|    rows = conn.execute(
   392|        """
   393|        SELECT status, COUNT(*) AS count
   394|        FROM budget_transaction_candidates
   395|        WHERE source_type='migros_receipt' AND substr(COALESCE(transaction_date,''),1,4)=?
   396|        GROUP BY status
   397|        """,
   398|        (year,),
   399|    ).fetchall()
   400|    by_status = {str(r["status"]): int(r["count"] or 0) for r in rows}
   401|    receipt_total = sum(by_status.values())
   402|    confirmed = by_status.get("confirmed", 0)
   403|    open_count = sum(count for status, count in by_status.items() if status not in {"confirmed", "ignored", "superseded", "reference_2025", "archived_reference"})
   404|    covered = conn.execute(
   405|        """
   406|        SELECT COUNT(*) FROM budget_transaction_candidates
   407|        WHERE status='covered_by_migros' AND substr(COALESCE(transaction_date,''),1,4)=?
   408|        """,
   409|        (year,),
   410|    ).fetchone()[0]
   411|    line_items = conn.execute(
   412|        """
   413|        SELECT COUNT(*)
   414|        FROM budget_import_line_items li
   415|        JOIN budget_transaction_candidates c ON c.transaction_candidate_id=li.transaction_candidate_id
   416|        WHERE c.source_type='migros_receipt' AND substr(COALESCE(c.transaction_date,''),1,4)=?
   417|        """,
   418|        (year,),
   419|    ).fetchone()[0]
   420|    article_candidates = conn.execute(
   421|        """
   422|        SELECT COUNT(*) FROM budget_transaction_candidates
   423|        WHERE substr(COALESCE(transaction_date,''),1,4)=?
   424|          AND (lower(COALESCE(source_file_label,'')) LIKE '%migros%' OR lower(COALESCE(source_file_label,'')) LIKE '%cumulus%')
   425|          AND COALESCE(source_type,'') NOT IN ('migros_receipt')
   426|          AND status NOT IN ('superseded','ignored','reference_2025','archived_reference')
   427|        """,
   428|        (year,),
   429|    ).fetchone()[0]
   430|    return {
   431|        "year": year,
   432|        "receipt_candidate_count": receipt_total,
   433|        "confirmed_receipt_count": confirmed,
   434|        "open_receipt_count": open_count,
   435|        "covered_by_migros_count": int(covered or 0),
   436|        "line_item_count": int(line_items or 0),
   437|        "article_candidate_count": int(article_candidates or 0),
   438|        "status_counts": by_status,
   439|        "dry_run": True,
   440|    }
   441|
   442|
   443|def confirm_migros_receipts_2026(conn: Connection, *, account_id: str, year: str = "2026") -> dict[str, Any]:
   444|    if not account_id:
   445|        raise HTTPException(status_code=422, detail="account_id required")
   446|    category_id, category_name = _category_id_name(conn, "Essen & Haushalt")
   447|    if not category_id:
   448|        raise HTTPException(status_code=422, detail="Essen & Haushalt category required")
   449|    rows = conn.execute(
   450|        """
   451|        SELECT * FROM budget_transaction_candidates
   452|        WHERE source_type='migros_receipt' AND substr(COALESCE(transaction_date,''),1,4)=?
   453|          AND status NOT IN ('confirmed','ignored','covered_by_migros','superseded','reference_2025','archived_reference')
   454|        ORDER BY transaction_date, created_at
   455|        """,
   456|        (year,),
   457|    ).fetchall()
   458|    ts = now()
   459|    confirmed = skipped = 0
   460|    errors: list[dict[str, str]] = []
   461|    for row in rows:
   462|        cid = str(row["transaction_candidate_id"])
   463|        try:
   464|            conn.execute(
   465|                """
   466|                UPDATE budget_transaction_candidates
   467|                SET proposed_category_id=?, proposed_category_name=?, confidence='0.98', requires_review=0,
   468|                    status='auto_categorized', classification='migros_receipt_food_household',
   469|                    review_reason='migros_receipt_auto_food_household', rule_id='migros_receipt_food_household',
   470|

Use offset=470 to continue reading (showing 300-469 of 1575 lines)


   760|
   761|def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, import_session_id: str | None = None) -> list[dict[str, Any]]:
   762|    clauses=[]; params=[]
   763|    if budget_year:
   764|        clauses.append("substr(transaction_date,1,4)=?"); params.append(budget_year)
   765|    if status == "open":
   766|        clauses.append("status IN ('pending','auto_categorized','needs_review','transfer_candidate')")
   767|    elif status:
   768|        clauses.append("status=?"); params.append(status)
   769|    elif not include_reference and not tab:
   770|        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','superseded','reference_2025','archived_reference','already_processed')")
   771|    if tab:
   772|        mapping = {
   773|            "auto": "requires_review=0 AND status IN ('pending','auto_categorized')",
   774|            "review": "requires_review=1 AND status IN ('needs_review','pending','transfer_candidate')",
   775|            "subscriptions": "classification='subscription_auto'",
   776|            "galaxus": "classification='galaxus_review'",
   777|            "migros_over_50": "classification='migros_review_over_threshold'",
   778|            "health": "classification='health_medical'",
   779|            "auto_transport": "classification='auto_transport'",
   780|            "shopping": "classification IN ('shopping','galaxus_review')",
   781|            "pets": "classification='pets'",
   782|            "unclear": "classification IN ('admin_other','unclear','legacy_csv_seed','card_uncategorized')",
   783|            "covered_by_migros": "status='covered_by_migros'",
   784|            "duplicates": "status IN ('duplicate','duplicate_candidate')",
   785|            "auto_ignored_duplicates": "status='auto_ignored_duplicate'",
   786|            "covered_by_source": "status IN ('covered_by_migros','covered_by_source')",
   787|            "transfers": "classification='transfer_candidate' OR status='transfer_candidate'",
   788|            "bank": "source_type IN ('bank_csv','raiffeisen_bank','akb_bank')",
   789|            "visa": "source_type='visa_credit_card' OR source_type='credit_card_csv'",
   790|            "migros": "source_type IN ('migros_receipts','migros_receipt') OR status='covered_by_migros'",
   791|            "raiffeisen": "source_type='raiffeisen_bank'",
   792|            "akb": "source_type='akb_bank'",
   793|            "income": "classification='income_candidate'",
   794|            "possible_income": "classification='possible_income'",
   795|            "money_in": "classification IN ('income_candidate','possible_income')",
   796|            "not_income_transfer": "classification IN ('transfer_candidate','investment_transfer','credit_card_payment') OR status='transfer_candidate'",
   797|            "credit_card_payment": "classification='credit_card_payment'",
   798|            "investment_transfers": "classification='investment_transfer'",
   799|            "ignored": "status IN ('ignored','covered_by_migros','covered_by_source','superseded','reference_2025','archived_reference')",
   800|        }
   801|        if tab in mapping:
   802|            clauses.append(mapping[tab])
   803|    if source_type:
   804|        clauses.append("source_type=?"); params.append(source_type)
   805|    if import_session_id:
   806|        clauses.append("import_session_id=?"); params.append(import_session_id)
   807|    if category_id:
   808|        clauses.append("proposed_category_id=?"); params.append(category_id)
   809|    if merchant:
   810|        clauses.append("lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"); params.append(f"%{merchant.lower()}%")
   811|    if rule:
   812|        clauses.append("lower(COALESCE(rule_id,'') || ' ' || COALESCE(rule_name,'') || ' ' || COALESCE(review_reason,'')) LIKE ?"); params.append(f"%{rule.lower()}%")
   813|    if min_confidence:
   814|        clauses.append("CAST(COALESCE(confidence,'0') AS REAL) >= CAST(? AS REAL)"); params.append(min_confidence)
   815|    if date_from:
   816|        clauses.append("transaction_date>=?"); params.append(date_from)
   817|    if date_to:
   818|        clauses.append("transaction_date<=?"); params.append(date_to)
   819|    if amount_min:
   820|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) >= CAST(? AS REAL)"); params.append(amount_min)
   821|    if amount_max:
   822|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) <= CAST(? AS REAL)"); params.append(amount_max)
   823|    where = "WHERE " + " AND ".join(clauses) if clauses else ""
   824|    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"}
   825|    order_col = sort_map.get(sort_by, "created_at")
   826|    direction = "ASC" if sort_dir.lower() == "asc" else "DESC"
   827|    rows = conn.execute(f"SELECT * FROM budget_transaction_candidates {where} ORDER BY {order_col} {direction}, created_at DESC LIMIT 500", params).fetchall()
   828|    return [_candidate_view(r) for r in rows]
   829|
   830|
   831|def reclassify_bank_income_candidates(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
   832|    """Re-score existing AKB/Raiffeisen candidates without confirming bookings."""
   833|    cid_income, cname_income = _category_by_name(conn, ["Lohn Marcel", "Einnahmen", "Sonstige Einnahmen", "Rückerstattungen"])
   834|    rows = conn.execute(
   835|        """
   836|        SELECT * FROM budget_transaction_candidates
   837|        WHERE source_type IN ('akb_bank','raiffeisen_bank','bank_csv')
   838|          AND substr(COALESCE(transaction_date,''),1,4)=?
   839|          AND status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded','reference_2025','archived_reference')
   840|        """,
   841|        (budget_year,),
   842|    ).fetchall()
   843|    counts = {"scanned_count": len(rows), "income_candidate_count": 0, "possible_income_count": 0, "transfer_candidate_count": 0, "investment_transfer_count": 0, "credit_card_payment_count": 0, "unclear_money_in_count": 0, "updated_count": 0}
   844|    ts = now()
   845|    for row in rows:
   846|        meta = {}
   847|        try:
   848|            meta = json.loads(row["notes"] or "{}") if str(row["notes"] or "").startswith("{") else {}
   849|        except json.JSONDecodeError:
   850|            meta = {}
   851|        is_positive = str(meta.get("transaction_type") or "").lower() == "income"
   852|        decision = _bank_candidate_decision(str(row["description"] or ""), is_positive=is_positive)
   853|        classification = str(decision["classification"])
   854|        if is_positive and classification == "bank_review":
   855|            counts["unclear_money_in_count"] += 1
   856|        if classification == "income_candidate": counts["income_candidate_count"] += 1
   857|        if classification == "possible_income": counts["possible_income_count"] += 1
   858|        if classification in {"transfer_candidate", "investment_transfer", "credit_card_payment"}: counts["transfer_candidate_count"] += 1
   859|        if classification == "investment_transfer": counts["investment_transfer_count"] += 1
   860|        if classification == "credit_card_payment": counts["credit_card_payment_count"] += 1
   861|        proposed_category_id = cid_income if classification in {"income_candidate", "possible_income"} else row["proposed_category_id"]
   862|        proposed_category_name = cname_income if classification in {"income_candidate", "possible_income"} else row["proposed_category_name"]
   863|        notes = json.dumps({"transaction_type": decision["transaction_type"], "reason": decision["review_reason"], "reclassified_by": "income_csv_recognition_v1"}, ensure_ascii=False)
   864|        before = (row["status"], row["classification"], row["review_reason"], row["proposed_category_id"])
   865|        after = (decision["status"], classification, decision["review_reason"], proposed_category_id)
   866|        if before != after:
   867|            conn.execute(
   868|                """
   869|                UPDATE budget_transaction_candidates
   870|                SET status=?, classification=?, requires_review=?, review_reason=?, confidence=?, notes=?, proposed_category_id=?, proposed_category_name=?, updated_at=?
   871|                WHERE transaction_candidate_id=?
   872|                """,
   873|                (decision["status"], classification, 1 if decision["requires_review"] else 0, decision["review_reason"], decision["confidence"], notes, proposed_category_id, proposed_category_name, ts, row["transaction_candidate_id"]),
   874|            )
   875|            counts["updated_count"] += 1
   876|    audit_id = record_audit_event(conn, source="vue_dashboard", action="bank_income_candidates_reclassified", entity_type="budget_transaction_candidate", entity_id=f"bank_income_reclassify_{budget_year}", new_values=counts, created_by="system")
   877|    conn.commit()
   878|    counts["audit_written"] = 1 if audit_id else 0
   879|    return counts
   880|
   881|
   882|def preview_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
   883|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   884|    if not row:
   885|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   886|    payload = payload or {}
   887|    cat_id = payload.get("category_id") or row["proposed_category_id"]
   888|    notes = row["notes"] or ""
   889|    tx_type = payload.get("transaction_type") or "expense"
   890|    try:
   891|        meta = json.loads(notes) if notes and notes.startswith("{") else {}
   892|        tx_type = payload.get("transaction_type") or meta.get("transaction_type") or tx_type
   893|    except json.JSONDecodeError:
   894|        pass
   895|    if not cat_id and tx_type in {"expense", "income"}:
   896|        raise HTTPException(status_code=422, detail="category required before confirm")
   897|    if cat_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat_id,)).fetchone():
   898|        raise HTTPException(status_code=422, detail="active category required before confirm")
   899|    if not payload.get("account_id"):
   900|        raise HTTPException(status_code=422, detail="account_id required before confirm")
   901|    if not row["transaction_date"]:
   902|        raise HTTPException(status_code=422, detail="transaction date required before confirm")
   903|    if not row["amount_original"]:
   904|        raise HTTPException(status_code=422, detail="amount required before confirm")
   905|    if not row["currency_original"]:
   906|        raise HTTPException(status_code=422, detail="currency required before confirm")
   907|    return {
   908|        "preview_id": new_id("preview"),
   909|        "summary": "Buchungskandidat übernehmen",
   910|        "warnings": ["Möglicher Duplikat-Treffer"] if row["duplicate_of_transaction_id"] else ([] if cat_id or tx_type == "transfer" else ["Kategorie fehlt"]),
   911|        "payload": {
   912|            "account_id": payload.get("account_id"),
   913|            "transaction_type": tx_type,
   914|            "transaction_date": payload.get("transaction_date") or row["transaction_date"],
   915|            "description": payload.get("description") or row["description"],
   916|            "payee": payload.get("payee") or row["merchant"],
   917|            "amount_original": row["amount_original"],
   918|            "currency_original": row["currency_original"],
   919|            "category_id": cat_id,
   920|            "source_type": "import_candidate",
   921|            "source_candidate_id": candidate_id,
   922|            "notes": f"Confirmed from transaction candidate {candidate_id}",
   923|        },
   924|    }
   925|
   926|
   927|def confirm_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   928|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   929|    if not row:
   930|

Use offset=930 to continue reading (showing 760-929 of 1575 lines)


   927|def confirm_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   928|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   929|    if not row:
   930|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   931|    if row["status"] == "confirmed":
   932|        raise HTTPException(status_code=409, detail="candidate already confirmed")
   933|    if row["status"] in {"covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "ignored", "reference_2025", "archived_reference"}:
   934|        raise HTTPException(status_code=409, detail="candidate is not confirmable")
   935|    if row["status"] in {"duplicate", "duplicate_candidate"} and not payload.get("allow_duplicate"):
   936|        raise HTTPException(status_code=409, detail="duplicate candidate requires explicit allow_duplicate")
   937|    if str(row["transaction_date"] or "")[:4] != str(payload.get("budget_year") or "2026"):
   938|        raise HTTPException(status_code=409, detail="candidate outside active budget year")
   939|    preview = preview_transaction_candidate(conn, candidate_id, payload)
   940|    result = confirm_budget_transaction(conn, preview["payload"])
   941|    ts = now()
   942|    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))
   943|    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")
   944|    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")
   945|    conn.commit()
   946|    return {"status": "confirmed", "entity_id": result["entity_id"], "audit_id": audit_id, "message": "Buchungskandidat übernommen"}
   947|
   948|
   949|def ignore_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   950|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   951|    if not row:
   952|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   953|    ts = now()
   954|    conn.execute("UPDATE budget_transaction_candidates SET status='ignored', notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (note, ts, candidate_id))
   955|    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")
   956|    conn.commit()
   957|    return {"status": "ignored", "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat ignoriert"}
   958|
   959|
   960|def reopen_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   961|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   962|    if not row:
   963|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   964|    if row["status"] == "confirmed":
   965|        raise HTTPException(status_code=409, detail="confirmed candidates cannot be reopened")
   966|    new_status = "needs_review" if row["requires_review"] else "pending"
   967|    ts = now()
   968|    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))
   969|    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")
   970|    conn.commit()
   971|    return {"status": new_status, "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat wieder geöffnet"}
   972|
   973|
   974|def update_transaction_candidate_category(conn: Connection, candidate_id: str, category_id: str, note: str | None = None) -> dict[str, Any]:
   975|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   976|    if not row:
   977|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   978|    if not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone():
   979|        raise HTTPException(status_code=422, detail="active category not found")
   980|    cname = _category_name(conn, category_id)
   981|    ts = now()
   982|    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))
   983|    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")
   984|    conn.commit()
   985|    return {"status": "updated", "entity_id": candidate_id, "audit_id": audit_id}
   986|
   987|
   988|def create_candidate_split(conn: Connection, candidate_id: str, splits: list[dict[str, Any]]) -> dict[str, Any]:
   989|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   990|    if not row:
   991|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   992|    if not splits:
   993|        raise HTTPException(status_code=422, detail="splits required")
   994|    ts = now()
   995|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
   996|    for split in splits:
   997|        amount = _decimal_text(split.get("amount_original"))
   998|        if not amount:
   999|            raise HTTPException(status_code=422, detail="split amount required")
  1000|        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))
  1001|    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))
  1002|    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")
  1003|    conn.commit()
  1004|    return {"status": "split_created", "entity_id": candidate_id, "split_count": len(splits), "audit_id": audit_id}
  1005|
  1006|
  1007|def mark_candidate_type(conn: Connection, candidate_id: str, candidate_type: str, note: str | None = None) -> dict[str, Any]:
  1008|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
  1009|    if not row:
  1010|        raise HTTPException(status_code=404, detail="transaction candidate not found")
  1011|    allowed = {
  1012|        "transfer": ("transfer_candidate", "transfer_candidate", "user_marked_transfer", "mark_transfer"),
  1013|        "investment_transfer": ("transfer_candidate", "investment_transfer", "user_marked_investment_transfer", "mark_investment_transfer"),
  1014|        "credit_card_payment": ("transfer_candidate", "credit_card_payment", "user_marked_credit_card_payment", "mark_credit_card_payment"),
  1015|    }
  1016|    if candidate_type not in allowed:
  1017|        raise HTTPException(status_code=422, detail="unsupported candidate type")
  1018|    status, classification, reason, action = allowed[candidate_type]
  1019|    ts = now()
  1020|    conn.execute("UPDATE budget_transaction_candidates SET status=?, classification=?, requires_review=1, review_reason=?, notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (status, classification, reason, note, ts, candidate_id))
  1021|    audit_id = record_audit_event(conn, source="vue_dashboard", action=action, entity_type="budget_transaction_candidate", entity_id=candidate_id, old_values={"status": row["status"], "classification": row["classification"]}, new_values={"status": status, "classification": classification}, user_text_note=note, created_by="user")
  1022|    conn.commit()
  1023|    return {"status": status, "classification": classification, "entity_id": candidate_id, "audit_id": audit_id}
  1024|
  1025|
  1026|def mark_candidate_transfer(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
  1027|    return mark_candidate_type(conn, candidate_id, "transfer", note)
  1028|
  1029|
  1030|def bulk_update_transaction_candidate_category(conn: Connection, *, candidate_ids: list[str], category_id: str, note: str | None = None) -> dict[str, int]:
  1031|    updated = skipped = 0
  1032|    for cid in candidate_ids:
  1033|        row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1034|        if not row or row["status"] == "confirmed":
  1035|            skipped += 1
  1036|            continue
  1037|        update_transaction_candidate_category(conn, cid, category_id, note or "Bulk category assignment")
  1038|        updated += 1
  1039|    return {"updated_count": updated, "skipped_count": skipped}
  1040|
  1041|
  1042|def _safe_auto_candidate_where() -> str:
  1043|    return """
  1044|        status IN ('pending','auto_categorized')
  1045|        AND COALESCE(requires_review, 0)=0
  1046|        AND duplicate_of_transaction_id IS NULL
  1047|        AND COALESCE(covered_by_source, '')=''
  1048|        AND (
  1049|            classification='subscription_auto'
  1050|            OR classification IN ('migros_auto_under_threshold','migros_receipt_food_household')
  1051|            OR classification='auto_transport'
  1052|        )
  1053|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%galaxus%'
  1054|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%digitec%'
  1055|    """
  1056|
  1057|
  1058|def preview_safe_auto_candidates(conn: Connection) -> dict[str, Any]:
  1059|    rows = conn.execute(
  1060|        f"""
  1061|        SELECT classification, COUNT(*) AS count
  1062|        FROM budget_transaction_candidates
  1063|        WHERE {_safe_auto_candidate_where()}
  1064|        GROUP BY classification
  1065|        ORDER BY classification
  1066|        """
  1067|    ).fetchall()
  1068|    total = sum(int(r["count"] or 0) for r in rows)
  1069|    return {
  1070|        "preview_id": new_id("preview"),
  1071|        "summary": f"{total} sichere Auto-Kandidaten bereit für Confirm",
  1072|        "count": total,
  1073|        "groups": [row_to_dict(r) for r in rows],
  1074|        "warnings": ["Preview only: Confirm benötigt Zielkonto und bucht nur sichere Kandidaten."],
  1075|    }
  1076|
  1077|
  1078|def batch_confirm_safe_candidates(conn: Connection, *, account_id: str, candidate_ids: list[str] | None = None) -> dict[str, int]:
  1079|    if not account_id:
  1080|        raise HTTPException(status_code=422, detail="account_id required")
  1081|    allowed_rows = conn.execute(f"SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE {_safe_auto_candidate_where()}").fetchall()
  1082|    allowed_ids = {str(r["transaction_candidate_id"]) for r in allowed_rows}
  1083|    requested_ids = set(candidate_ids or allowed_ids)
  1084|    confirmed = skipped = 0
  1085|    for cid in sorted(requested_ids):
  1086|        if cid not in allowed_ids:
  1087|            skipped += 1
  1088|            continue
  1089|        confirm_transaction_candidate(conn, cid, {"account_id": account_id})
  1090|        confirmed += 1
  1091|    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")
  1092|    conn.commit()
  1093|    return {"confirmed_count": confirmed, "skipped_count": skipped}
  1094|
  1095|
  1096|def candidate_user_count_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
  1097|    rows = conn.execute(
  1098|        """
  1099|        SELECT status, classification, requires_review, duplicate_of_transaction_id
  1100|        FROM budget_transaction_candidates
  1101|        WHERE substr(COALESCE(transaction_date,''),1,4)=?
  1102|        """,
  1103|        (budget_year,),
  1104|    ).fetchall()
  1105|    def count(pred):
  1106|        return sum(1 for r in rows if pred(r))
  1107|    normal_open = lambda r: r["status"] in USER_OPEN_STATUSES
  1108|    duplicates = lambda r: r["status"] in USER_DUPLICATE_STATUSES or bool(r["duplicate_of_transaction_id"])
  1109|    covered = lambda r: r["status"] in {"covered_by_source", "covered_by_migros"}
  1110|    already = lambda r: r["status"] in {"already_processed", "auto_ignored_duplicate"}
  1111|    return {
  1112|        "open_total": count(normal_open),
  1113|        "auto_categorized": count(lambda r: r["status"] in {"pending", "auto_categorized"} and not bool(r["requires_review"])),
  1114|        "review_needed": count(lambda r: normal_open(r) and bool(r["requires_review"])),
  1115|        "galaxus_digitec": count(lambda r: normal_open(r) and r["classification"] == "galaxus_review"),
  1116|        "migros_over_50": count(lambda r: normal_open(r) and r["classification"] == "migros_review_over_threshold"),
  1117|        "covered_by_source": count(covered),
  1118|        "covered_by_migros": count(lambda r: r["status"] == "covered_by_migros"),
  1119|        "unclear": count(lambda r: normal_open(r) and r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
  1120|        "possible_duplicates": count(duplicates),
  1121|        "transfer_candidates": count(lambda r: normal_open(r) and (r["status"] == "transfer_candidate" or r["classification"] == "transfer_candidate")),
  1122|        "income_candidates": count(lambda r: normal_open(r) and r["classification"] == "income_candidate"),
  1123|        "possible_income": count(lambda r: normal_open(r) and r["classification"] == "possible_income"),
  1124|        "already_processed": count(already),
  1125|    }
  1126|
  1127|
  1128|def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
  1129|    return candidate_user_count_summary(conn, budget_year=budget_year)
  1130|
  1131|
  1132|BLOCKED_BULK_EDIT_STATUSES = {"confirmed", "covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference"}
  1133|BLOCKED_BULK_CONFIRM_STATUSES = BLOCKED_BULK_EDIT_STATUSES | {"ignored", "duplicate", "duplicate_candidate", "transfer_candidate"}
  1134|
  1135|
  1136|def _batch_confirm_block_reason(row: Any, *, budget_year: str) -> str | None:
  1137|    status = str(row["status"] or "")
  1138|    classification = str(row["classification"] or "")
  1139|    if status in {"duplicate", "duplicate_candidate"}:
  1140|        return "duplicate_candidate_blocked"
  1141|    if status in {"covered_by_source", "covered_by_migros"}:
  1142|        return status
  1143|    if status in {"auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference", "confirmed", "ignored"}:
  1144|        return f"not_confirmable:{status}"
  1145|    if status == "transfer_candidate" or "transfer" in classification:
  1146|        return "transfer_candidate_blocked"
  1147|    if str(row["transaction_date"] or "")[:4] != str(budget_year):
  1148|        return "outside_active_budget_year"
  1149|    if not row["proposed_category_id"] and classification not in {"income_candidate", "possible_income"}:
  1150|        return "missing_category"
  1151|    if not row["transaction_date"]:
  1152|        return "missing_transaction_date"
  1153|    if not row["amount_original"]:
  1154|        return "missing_amount"
  1155|    if not row["currency_original"]:
  1156|        return "missing_currency"
  1157|    return None
  1158|
  1159|
  1160|def _blocker_summary(conflicts: list[dict[str, Any]]) -> dict[str, int]:
  1161|    def count(needle: str) -> int:
  1162|        return sum(1 for c in conflicts if needle in str(c.get("reason") or ""))
  1163|    return {
  1164|        "duplicate_candidates": count("duplicate"),
  1165|        "covered_by_source": count("covered_by_source") + count("covered_by_migros"),
  1166|        "transfer_candidates": count("transfer"),
  1167|        "without_category": count("missing_category"),
  1168|        "missing_required_fields": count("missing_transaction_date") + count("missing_amount") + count("missing_currency"),
  1169|        "not_confirmable": count("not_confirmable"),
  1170|    }
  1171|
  1172|
  1173|def preview_batch_confirm_candidates(conn: Connection, *, candidate_ids: list[str], account_id: str, budget_year: str = "2026") -> dict[str, Any]:
  1174|    rows = []
  1175|    conflicts: list[dict[str, Any]] = []
  1176|    categories: dict[str, int] = {}
  1177|    sources: dict[str, int] = {}
  1178|    possible_duplicates = 0
  1179|    for cid in candidate_ids:
  1180|        row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1181|        if not row:
  1182|            conflicts.append({"candidate_id": cid, "reason": "missing"}); continue
  1183|        reason = _batch_confirm_block_reason(row, budget_year=budget_year)
  1184|        if reason:
  1185|            conflicts.append({"candidate_id": cid, "reason": reason}); continue
  1186|        try:
  1187|            preview_transaction_candidate(conn, cid, {"account_id": account_id, "category_id": row["proposed_category_id"], "budget_year": budget_year})
  1188|        except Exception as exc:
  1189|            conflicts.append({"candidate_id": cid, "reason": str(getattr(exc, "detail", str(exc)))}); continue
  1190|        rows.append(row)
  1191|        categories[str(row["proposed_category_name"] or "Review nötig")] = categories.get(str(row["proposed_category_name"] or "Review nötig"), 0) + 1
  1192|        sources[str(row["source_type"] or "Quelle unbekannt")] = sources.get(str(row["source_type"] or "Quelle unbekannt"), 0) + 1
  1193|        if row["duplicate_of_transaction_id"]:
  1194|            possible_duplicates += 1
  1195|    if not rows and conflicts:
  1196|        raise HTTPException(status_code=409, detail={"message": "no confirmable candidates", "conflicts": conflicts, "blockers": _blocker_summary(conflicts)})
  1197|    preview_id = new_id("batchprev")
  1198|    blockers = _blocker_summary(conflicts)
  1199|    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, "sources": sources, "blockers": blockers}, created_by="user")
  1200|    conn.commit()
  1201|    return {"preview_id": preview_id, "count": len(rows), "categories": categories, "sources": sources, "possible_duplicates": possible_duplicates, "conflicts": conflicts, "blockers": blockers, "can_confirm": len(conflicts) == 0 and len(rows) > 0, "requires_explicit_confirm": True}
  1202|
  1203|
  1204|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]:
  1205|    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": []}
  1206|    if action == "confirm" and not preview_id:
  1207|        raise HTTPException(status_code=422, detail="batch confirm requires preview_id")
  1208|    for cid in candidate_ids:
  1209|        try:
  1210|            row_for_year = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1211|            if not row_for_year:
  1212|                raise HTTPException(status_code=404, detail="candidate not found")
  1213|            if str(row_for_year["transaction_date"] or "")[:4] != str(budget_year):
  1214|                raise HTTPException(status_code=409, detail="candidate outside active budget year")
  1215|            if action == "category":
  1216|                if row_for_year["status"] in BLOCKED_BULK_EDIT_STATUSES:
  1217|                    raise HTTPException(status_code=409, detail=f"candidate not editable:{row_for_year['status']}")
  1218|                if not category_id: raise HTTPException(status_code=422, detail="category_id required")
  1219|                update_transaction_candidate_category(conn, cid, category_id, note or "Batch category assignment"); counts["updated_count"] += 1
  1220|            elif action == "confirm":
  1221|                reason = _batch_confirm_block_reason(row_for_year, budget_year=budget_year)
  1222|                if reason:
  1223|                    raise HTTPException(status_code=409, detail=reason)
  1224|                confirm_transaction_candidate(conn, cid, {"account_id": account_id, "budget_year": budget_year}); counts["confirmed_count"] += 1
  1225|            elif action == "ignore":
  1226|                ignore_transaction_candidate(conn, cid, note or "Batch ignore"); counts["ignored_count"] += 1
  1227|            elif action == "reopen":
  1228|                reopen_transaction_candidate(conn, cid, note or "Batch reopen"); counts["reopened_count"] += 1
  1229|            elif action == "transfer":
  1230|                mark_candidate_transfer(conn, cid, note or "Batch transfer"); counts["transfer_count"] += 1
  1231|            elif action == "covered_by_migros":
  1232|                ts = now(); row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
  1233|                if not row: raise HTTPException(status_code=404, detail="candidate not found")
  1234|                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))
  1235|                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")
  1236|                counts["covered_by_migros_count"] += 1
  1237|            else:
  1238|                raise HTTPException(status_code=422, detail="invalid batch action")
  1239|        except Exception as exc:
  1240|            counts["skipped_count"] += 1
  1241|            detail = getattr(exc, "detail", str(exc))
  1242|            counts["errors"].append({"candidate_id": cid, "error": str(detail)})
  1243|    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")
  1244|    conn.commit()
  1245|    return counts | {"audit_id": audit_id}
  1246|
  1247|
  1248|def confirm_candidate_split(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
  1249|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
  1250|    if not row:
  1251|        raise HTTPException(status_code=404, detail="transaction candidate not found")
  1252|    if row["status"] in {"confirmed", "ignored", "covered_by_migros", "superseded"}:
  1253|        raise HTTPException(status_code=409, detail="candidate is not splittable")
  1254|    splits = payload.get("splits") or []
  1255|    if not payload.get("account_id"):
  1256|        raise HTTPException(status_code=422, detail="account_id required")
  1257|

Use offset=1257 to continue reading (showing 927-1256 of 1575 lines)


Explanation:
**Evaluating implementation issues**

I’ve got a lot already implemented, but it seems the duplicate status lacks a specific “duplicate_blocked” feature. The normal tab might exclude this issue, but the “status=open” currently doesn’t include any duplicates. I think I need to run some tests to clarify this. I also need to better understand the schema columns and see if I can utilize the existing test helpers for this purpose. Let’s dig into that!


     1|from __future__ import annotations
     2|
     3|from jarvis_finance.services.budget_accounts import confirm_create_budget_account
     4|from jarvis_finance.services.budget_categories import confirm_create_category
     5|from jarvis_finance.services.budget_csv_imports import (
     6|    detect_import_profile,
     7|    import_budget_csv_text,
     8|    parse_budget_csv_text,
     9|)
    10|from jarvis_finance.services.budget_imports import confirm_transaction_candidate, list_transaction_candidates, reclassify_bank_income_candidates
    11|from jarvis_finance.services.budget_transactions import confirm_budget_transaction
    12|from jarvis_finance.storage.database import connect_memory
    13|from jarvis_finance.storage.migrations import apply_migrations
    14|
    15|
    16|def db():
    17|    conn = connect_memory()
    18|    apply_migrations(conn)
    19|    return conn
    20|
    21|
    22|def setup_base(conn):
    23|    account = confirm_create_budget_account(conn, {"name": "Budget Konto", "account_type": "cash", "currency": "CHF"})["entity_id"]
    24|    food = confirm_create_category(conn, {"name": "Essen & Haushalt", "category_type": "expense"})["entity_id"]
    25|    income = confirm_create_category(conn, {"name": "Lohn Marcel", "category_type": "income"})["entity_id"]
    26|    return account, food, income
    27|
    28|
    29|def test_import_profiles_detect_real_drive_csv_columns() -> None:
    30|    assert detect_import_profile(["TransactionId", "CardId", "Date", "Amount", "Currency", "MerchantName"]) == "visa_credit_card"
    31|    assert detect_import_profile(["Datum", "Zeit", "Filiale", "Kassennummer", "Transaktionsnummer", "Artikel", "Umsatz"]) == "migros_receipts"
    32|    assert detect_import_profile(["IBAN", "Booked At", "Text", "Credit/Debit Amount", "Balance", "Valuta Date"]) == "raiffeisen_bank"
    33|    assert detect_import_profile(["Buchung", "Valuta", "Buchungstext", "Belastung", "Gutschrift", "Saldo CHF"]) == "akb_bank"
    34|
    35|
    36|def test_dry_run_parses_all_profiles_without_writing_candidates() -> None:
    37|    conn = db(); _account, food, income = setup_base(conn)
    38|    samples = {
    39|        "visa_credit_card": "TransactionId,CardId,Date,ValutaDate,Amount,Currency,MerchantName,Details\nT1,C1,2026-01-03,2026-01-04,-12.34,CHF,APPLE.COM,Bill\n",
    40|        "migros_receipts": "Datum;Zeit;Filiale;Kassennummer;Transaktionsnummer;Artikel;Menge;Aktion;Umsatz\n02.01.2026;10:00;Migros Test;1;99;Artikel A;1;;1.10\n02.01.2026;10:00;Migros Test;1;99;Artikel B;1;;2.20\n",
    41|        "raiffeisen_bank": "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\nCH00;2026-01-05;Lohn Marcel;123.45;1000;2026-01-05\n",
    42|        "akb_bank": "Buchung;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF\n05.01.2026;05.01.2026;Miete Verwaltung;88.00;;1000\n",
    43|    }
    44|    for profile, text in samples.items():
    45|        result = import_budget_csv_text(conn, profile, text, source_file_label=f"{profile}.csv", dry_run=True, default_category_id=food, income_category_id=income)
    46|        assert result["profile"] == profile
    47|        assert result["dry_run"] is True
    48|        assert result["parsed_rows"] >= 1
    49|    assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates").fetchone()[0] == 0
    50|
    51|
    52|def test_import_is_idempotent_and_marks_already_processed_rows() -> None:
    53|    conn = db(); _account, food, _income = setup_base(conn)
    54|    csv_text = "TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nTID-1,CARD-1,2026-02-01,-10.00,CHF,Netflix,Subscription\n"
    55|
    56|    first = import_budget_csv_text(conn, "visa_credit_card", csv_text, source_file_label="visa.csv", dry_run=False, default_category_id=food)
    57|    second = import_budget_csv_text(conn, "visa_credit_card", csv_text, source_file_label="visa.csv", dry_run=False, default_category_id=food)
    58|
    59|    assert first["candidate_count"] == 1
    60|    assert second["candidate_count"] == 0
    61|    assert second["already_processed_count"] == 1
    62|    assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates").fetchone()[0] == 1
    63|
    64|
    65|def test_import_marks_existing_confirmed_transaction_as_duplicate_existing() -> None:
    66|    conn = db(); account, food, _income = setup_base(conn)
    67|    confirm_budget_transaction(conn, {
    68|        "account_id": account,
    69|        "transaction_type": "expense",
    70|        "transaction_date": "2026-02-10",
    71|        "description": "Manual Netflix",
    72|        "amount_original": "10.00",
    73|        "currency_original": "CHF",
    74|        "category_id": food,
    75|        "source_type": "manual",
    76|    })
    77|    csv_text = "TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nTID-2,CARD-1,2026-02-10,-10.00,CHF,Netflix,Subscription\n"
    78|
    79|    result = import_budget_csv_text(conn, "visa_credit_card", csv_text, source_file_label="visa.csv", dry_run=False, default_category_id=food)
    80|    row = list_transaction_candidates(conn, tab="duplicates", include_reference=True)[0]
    81|
    82|    assert result["possible_duplicate_count"] == 1
    83|    assert row["status"] == "duplicate_candidate"
    84|
    85|
    86|def test_bank_import_classifies_transfers_true_wealth_credit_card_payment_and_income() -> None:
    87|    conn = db(); _account, _food, income = setup_base(conn)
    88|    csv_text = "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\nCH1;2026-03-01;Übertrag an AKB eigenes Konto;-100.00;0;2026-03-01\nCH1;2026-03-02;True Wealth Einzahlung;-200.00;0;2026-03-02\nCH1;2026-03-03;VISA Kartenabrechnung;-300.00;0;2026-03-03\nCH1;2026-03-04;Lohn Marcel;400.00;0;2026-03-04\n"
    89|
    90|    result = import_budget_csv_text(conn, "raiffeisen_bank", csv_text, source_file_label="raiffeisen.csv", dry_run=False, income_category_id=income)
    91|    candidates = list_transaction_candidates(conn, include_reference=True, sort_by="date", sort_dir="asc")
    92|    classifications = [c["classification"] for c in candidates]
    93|
    94|    assert result["transfer_candidate_count"] >= 3
    95|    assert "transfer_candidate" in classifications
    96|    assert "investment_transfer" in classifications
    97|    assert "credit_card_payment" in classifications
    98|    assert "income_candidate" in classifications
    99|    assert all(c["status"] != "confirmed" for c in candidates)
   100|
   101|
   102|def test_migros_import_creates_one_receipt_candidate_and_detail_line_items() -> None:
   103|    conn = db(); _account, food, _income = setup_base(conn)
   104|    csv_text = "Datum;Zeit;Filiale;Kassennummer;Transaktionsnummer;Artikel;Menge;Aktion;Umsatz\n02.01.2026;10:00;Migros Test;1;99;Artikel A;1;;1.10\n02.01.2026;10:00;Migros Test;1;99;Artikel B;1;;2.20\n"
   105|
   106|    result = import_budget_csv_text(conn, "migros_receipts", csv_text, source_file_label="migros.csv", dry_run=False, default_category_id=food)
   107|
   108|    assert result["candidate_count"] == 1
   109|    assert result["line_item_count"] == 2
   110|    assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates WHERE source_type='migros_receipts'").fetchone()[0] == 1
   111|    assert conn.execute("SELECT COUNT(*) FROM budget_import_line_items").fetchone()[0] == 2
   112|
   113|
   114|def test_bank_income_recognition_patterns_and_negative_guards() -> None:
   115|    conn = db(); _account, _food, income = setup_base(conn)
   116|    csv_text = "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\n" \
   117|        "CH1;2026-04-01;ERNE AG Lohnzahlung;10.00;0;2026-04-01\n" \
   118|        "CH1;2026-04-02;Musikschule Lohn Melanie;11.00;0;2026-04-02\n" \
   119|        "CH1;2026-04-03;Gasser Bauunternehmen Salär;12.00;0;2026-04-03\n" \
   120|        "CH1;2026-04-04;Bonus Gratifikation;13.00;0;2026-04-04\n" \
   121|        "CH1;2026-04-05;Solarstrom Rückvergütung;14.00;0;2026-04-05\n" \
   122|        "CH1;2026-04-06;AKB Umbuchung eigenes Konto;15.00;0;2026-04-06\n" \
   123|        "CH1;2026-04-07;True Wealth Einzahlung;16.00;0;2026-04-07\n" \
   124|        "CH1;2026-04-08;VISA Kartenabrechnung;17.00;0;2026-04-08\n"
   125|    import_budget_csv_text(conn, "raiffeisen_bank", csv_text, source_file_label="raiffeisen.csv", dry_run=False, income_category_id=income)
   126|    rows = list_transaction_candidates(conn, include_reference=True, sort_by="date", sort_dir="asc")
   127|    by_desc = {r["description"]: r for r in rows}
   128|    assert by_desc["ERNE AG Lohnzahlung"]["classification"] == "income_candidate"
   129|    assert by_desc["Musikschule Lohn Melanie"]["classification"] == "income_candidate"
   130|    assert by_desc["Gasser Bauunternehmen Salär"]["classification"] == "income_candidate"
   131|    assert by_desc["Bonus Gratifikation"]["classification"] == "income_candidate"
   132|    assert by_desc["Solarstrom Rückvergütung"]["classification"] == "income_candidate"
   133|    assert by_desc["AKB Umbuchung eigenes Konto"]["classification"] == "transfer_candidate"
   134|    assert by_desc["True Wealth Einzahlung"]["classification"] == "investment_transfer"
   135|    assert by_desc["VISA Kartenabrechnung"]["classification"] == "credit_card_payment"
   136|
   137|
   138|def test_reclassify_existing_akb_raiffeisen_candidates_without_confirming() -> None:
   139|    conn = db(); account, _food, income = setup_base(conn)
   140|    import_budget_csv_text(conn, "akb_bank", "Buchung;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF\n01.04.2026;01.04.2026;ERNE Lohn;;10.00;0\n02.04.2026;02.04.2026;AKB Raiffeisen Übertrag;;10.00;0\n", source_file_label="akb.csv", dry_run=False, income_category_id=income)
   141|    conn.execute("UPDATE budget_transaction_candidates SET status='needs_review', classification='bank_review', proposed_category_id=NULL, proposed_category_name=NULL")
   142|    result = reclassify_bank_income_candidates(conn, budget_year="2026")
   143|    rows = list_transaction_candidates(conn, include_reference=True, sort_by="date", sort_dir="asc")
   144|    assert result["updated_count"] >= 2
   145|    assert rows[0]["classification"] == "income_candidate"
   146|    assert rows[0]["status"] == "pending"
   147|    assert rows[1]["classification"] == "transfer_candidate"
   148|    assert all(r["status"] != "confirmed" for r in rows)
   149|    confirmed = confirm_transaction_candidate(conn, rows[0]["transaction_candidate_id"], {"account_id": account, "category_id": income, "transaction_type": "income", "budget_year": "2026"})
   150|    assert confirmed["status"] == "confirmed"
   151|    tx = conn.execute("SELECT transaction_type, source_type, source_candidate_id, status FROM budget_transactions WHERE budget_transaction_id=?", (confirmed["entity_id"],)).fetchone()
   152|    assert dict(tx) == {"transaction_type": "income", "source_type": "import_candidate", "source_candidate_id": rows[0]["transaction_candidate_id"], "status": "confirmed"}
   153|


Explanation:
**Planning tests for budget imports**

I need to write tests for the budget imports, focusing on the `budget_csv_imports`, specifically using `source_type` as `migros_receipts`. I'll have to add the `duplicate_blocked` status, which might require minimal implementation. It’s important also to use the patch method here. I'll inspect the results of the `budget_csv_imports` to ensure the `migros` import is functioning as expected. Let's make sure everything is running smoothly!

terminal

python - <<'PY'
from pathlib import Path
p=Path('src/jarvis_finance/services/budget_csv_imports.py')
for i,l in enumerate(p.read_text().splitlines(),1):
 if 'migros' in l.lower() or 'seed_migros' in l: print(i,l[:180])
PY


Output:
13     seed_migros_candidates_from_rows,
48         default_rules=("migros_covered_by_migros", "subscriptions_review", "galaxus_review"),
50     "migros_receipts": ImportProfile(
51         name="migros_receipts",
52         source_type="migros_receipts",
53         filename_patterns=("migros", "cumulus"),
107         return "migros_receipts"
169         seed = seed_credit_card_candidates_from_rows(conn, rows, source_file_label=source_file_label, subscription_category_id=default_category_id, migros_covered=True, candidate_s
170     elif profile.name == "migros_receipts":
171         seed = seed_migros_candidates_from_rows(conn, rows, source_file_label=source_file_label, auto_category_id=default_category_id, candidate_source_type=profile.source_type)


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


[Your active task list was preserved across context compression]
- [>] dup-0. Arbeitsbasis prüfen; relevante Budget-Import-/Review-/Confirm-Services und UI inventarisieren (in_progress)
- [ ] dup-1. RED-Tests für Duplicate-Filter, Migros receipt_key/Fingerprint, Confirm Guard, Batch Guard, UI Duplikat-Tab schreiben und failen sehen (pending)
- [ ] dup-2. Backend: Duplicate-Statuslogik, normaler Review-Filter, Confirm-/Batch-Guard, Override-Endpoint mit Begründung/Audit implementieren (pending)
- [ ] dup-3. Frontend: Review-Liste ohne Duplikate, Duplikat-Tab/Badges/disabled Confirm/Fehlerliste umsetzen (pending)
- [ ] dup-4. Runtime: Backup, Dry Run, Duplikate auditierbar markieren, keine produktiven Buchungen erzeugen (pending)
- [ ] dup-5. Verifikation: compile, pytest, npm test/build, secret scans, Git-Safety, diff-check, Browser Sanity (pending)
- [ ] dup-6. Commit, Push, Remote-Hash, Abschlussbericht (pending)


todo

Arbeitsbasis prüfen; relevante Budget-Import-/Review-/Confirm-Services und UI inventarisieren


RED-Tests für Duplicate-Filter, Migros receipt_key/Fingerprint, Confirm Guard, Batch Guard, UI Duplikat-Tab schreiben und failen sehen

Backend: Duplicate-Statuslogik, normaler Review-Filter, Confirm-/Batch-Guard, Override-Endpoint mit Begründung/Audit implementieren

Frontend: Review-Liste ohne Duplikate, Duplikat-Tab/Badges/disabled Confirm/Fehlerliste umsetzen

Runtime: Backup, Dry Run, Duplikate auditierbar markieren, keine produktiven Buchungen erzeugen

Verifikation: compile, pytest, npm test/build, secret scans, Git-Safety, diff-check, Browser Sanity

Commit, Push, Remote-Hash, Abschlussbericht


terminal

git status --short --branch && python - <<'PY'
from pathlib import Path
for path in ['src/jarvis_finance/services/budget_imports.py','src/jarvis_finance/services/budget_csv_imports.py','frontend/src/api/budget.ts','frontend/src/stores/budgetReviewStore.ts']:
 print('\n---',path)
 p=Path(path)
 print(p.exists(), p.stat().st_size if p.exists() else '')
PY


Output:
## main...origin/main

--- src/jarvis_finance/services/budget_imports.py
True 107661

--- src/jarvis_finance/services/budget_csv_imports.py
True 7797

--- frontend/src/api/budget.ts
True 39825

--- frontend/src/stores/budgetReviewStore.ts
True 1543


Explanation:
**Inspecting API routes**

Okay, I should check the status to ensure no hash is present. I’m thinking I also need to inspect the API routes related to the candidates. Making sure everything is clean is crucial, so I’ll carefully look through the routes to maintain functionality. Let’s not overlook any details here; thorough inspection is key! Once I gather my findings, I can adjust or document anything that seems off.


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

Use offset=520 to continue reading (showing 300-519 of 958 lines)