   237|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") -> dict[str, int]:
   238|    threshold = Decimal(threshold_chf)
   239|    auto_category_name = _category_name(conn, auto_category_id)
   240|    grouped: dict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
   241|    ignored = 0
   242|    for idx, raw in enumerate(rows, start=1):
   243|        low = {_normalise(k): v for k, v in raw.items()}
   244|        tx_date = _date_text(low.get("datum") or low.get("date"))
   245|        time = str(low.get("zeit") or low.get("time") or "").strip()
   246|        store = str(low.get("filiale") or low.get("store") or "Migros").strip()
   247|        register = str(low.get("kasse") or low.get("kassennummer") or low.get("register") or "").strip()
   248|        txn = str(low.get("transaktion") or low.get("transaktionsnummer") or low.get("bon") or low.get("bon-id") or "").strip()
   249|        amount = _decimal_text(low.get("umsatz") or low.get("betrag") or low.get("amount"))
   250|        if not tx_date or not amount:
   251|            ignored += 1
   252|            continue
   253|        key = _fingerprint([tx_date, time, store, register, txn])
   254|        grouped[key].append((idx, raw))
   255|    created = auto = review = line_count = 0
   256|    for key, items in grouped.items():
   257|        first_low = {_normalise(k): v for k, v in items[0][1].items()}
   258|        tx_date = _date_text(first_low.get("datum") or first_low.get("date"))
   259|        time = str(first_low.get("zeit") or first_low.get("time") or "").strip()
   260|        store = str(first_low.get("filiale") or first_low.get("store") or "Migros").strip() or "Migros"
   261|        register = str(first_low.get("kasse") or first_low.get("kassennummer") or first_low.get("register") or "").strip()
   262|        txn = str(first_low.get("transaktion") or first_low.get("transaktionsnummer") or first_low.get("bon") or first_low.get("bon-id") or "").strip()
   263|        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)
   264|        amount = format(total, "f")
   265|        needs_review = total >= threshold or not auto_category_id
   266|        status = "needs_review" if needs_review else "pending"
   267|        classification = "migros_review_over_threshold" if needs_review else "migros_auto_under_threshold"
   268|        review_reason = "migros_receipt_over_threshold" if needs_review else "migros_receipt_under_threshold_auto"
   269|        row_ref = f"BON:{tx_date}:{time}:{store}:{register}:{txn}"[:180]
   270|        ts = now()
   271|        candidate_id = "btxcand_" + _candidate_key(source_file_label, row_ref, tx_date, amount, "Migros Bon")
   272|        inserted = _insert_candidate(conn, {
   273|            "transaction_candidate_id": candidate_id,
   274|            "source_file_label": source_file_label,
   275|            "source_row_or_range": row_ref,
   276|            "source_type": "migros_receipt",
   277|            "transaction_date": tx_date,
   278|            "description": f"Migros Bon {store}"[:300],
   279|            "merchant": store,
   280|            "amount_original": amount,
   281|            "currency_original": "CHF",
   282|            "proposed_category_id": None if needs_review else auto_category_id,
   283|            "proposed_category_name": None if needs_review else auto_category_name,
   284|            "confidence": "0.90" if not needs_review else "0.60",
   285|            "requires_review": needs_review,
   286|            "status": status,
   287|            "notes": json.dumps({"threshold_chf": threshold_chf, "line_items": len(items)}, ensure_ascii=False),
   288|            "classification": classification,
   289|            "review_reason": review_reason,
   290|            "rule_id": "migros_under_threshold_auto" if not needs_review else "migros_over_threshold_review",
   291|            "source_priority": 90,
   292|            "receipt_key": key,
   293|            "raw_fingerprint": key,
   294|            "created_at": ts,
   295|            "updated_at": ts,
   296|        })
   297|        if inserted:
   298|            created += 1
   299|            auto += 0 if needs_review else 1
   300|            review += 1 if needs_review else 0
   301|            for idx, raw in items:
   302|                low = {_normalise(k): v for k, v in raw.items()}
   303|                item_amount = _decimal_text(low.get("umsatz") or low.get("betrag") or low.get("amount"))
   304|                conn.execute(
   305|                    """
   306|                    INSERT OR IGNORE INTO budget_import_line_items(
   307|                        line_item_id, transaction_candidate_id, source_file_label, receipt_key,
   308|                        source_row_or_range, item_name, quantity, is_promotion, amount_original,
   309|                        currency_original, raw_fingerprint, created_at
   310|                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, ?)
   311|                    """,
   312|                    ("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),
   313|                )
   314|                line_count += 1
   315|    conn.commit()
   316|    return {"receipt_candidate_count": created, "candidate_count": created, "line_item_count": line_count, "auto_assigned_count": auto, "review_required_count": review, "ignored_count": ignored}
   317|
   318|
   319|def _matches_any(text: str, patterns: list[str]) -> bool:
   320|    hay = _normalise(text)
   321|    return any(_normalise(p) in hay for p in patterns)
   322|
   323|
   324|PHASE16_RULES: list[dict[str, Any]] = [
   325|    {"id": "p16_food_supermarket", "name": "Essen & Haushalt · Supermarkt/Lebensmittel", "category": "Essen & Haushalt", "classification": "food_household", "confidence": "0.88", "patterns": ["Migros", "Coop", "ALDI SUISSE", "Lidl", "Denner", "Volg", "Schmidt's Märkte", "SPAR", "Yavuz Market", "Supermercato Dpiu"]},
   326|    {"id": "p16_food_convenience", "name": "Essen & Haushalt · Convenience/Kiosk", "category": "Essen & Haushalt", "classification": "food_household", "confidence": "0.84", "patterns": ["Migrolino", "Coop Pronto", "Coop Vitality", "Avec", "K Kiosk", "Sonneland Kiosk"]},
   327|    {"id": "p16_food_restaurant", "name": "Essen & Haushalt · Restaurant/Takeaway", "category": "Essen & Haushalt", "classification": "food_household", "confidence": "0.82", "patterns": ["McDonald's", "Burger King", "Holy Cow", "Subway", "Brezelkönig", "Dunkin", "Restaurant Smiling Fish", "Restaurant Aquarena", "FELFEL AG", "Just Eat", "PostFinance Restaurant W", "Erne Chuchi", "DORY & DU", "Marche Burger", "Ristorante Sale e Pepe", "Marché Restaurants", "Herrlisberg Gran Bar", "Restaurant Pantanal", "Ristorante Pizzeria La Pa", "Mammami", "Restaurant Zoologischer G", "Restaurant Big Sterne", "Artigiano Pizzeria Nap", "Restaurant Waldkantine", "Restaurant Tannegg", "Gasthaus Felsgarten", "Lenzo Palace", "kunz AG art of sweets", "Spezialitäten-Metzgerei Bechinger", "Müller Drogeriemarkt", "Sonneland AG Shop", "Sportcenter Bustelbach"]},
   328|    {"id": "p16_home_garden", "name": "Hausrat / Möbel & Garten", "category": "Hausrat / Möbel & Garten", "classification": "home_garden", "confidence": "0.86", "patterns": ["IKEA", "Möbelmarkt Dogern", "JUMBO", "Boni-Shop", "Landi", "Zulauf Gartencenter", "Grillfurst Schweiz GmbH"]},
   329|    {"id": "p16_leisure_subscriptions", "name": "Freizeit / Ausflüge / Abos", "category": "Freizeit / Ausflüge / Abos", "classification": "subscription_auto", "confidence": "0.90", "patterns": ["Apple", "Apple.com", "Google", "Amazon Prime", "Netflix", "Disney", "Disney Plus", "Disney Streaming", "DisneyPlus", "Paramount+", "Prime Video CH", "Babbel", "OpenAI", "ChatGPT", "Europa-Park", "Knie's Kinderzoo", "Zoo Zürich", "Zoo Basel", "Zoo Hasel", "Technorama", "Legionarspfad", "Bad Schinznach", "aquabasilea", "Thermalbad Zurzach", "Alpamare", "sole uno", "Thermalquelle", "Kurhotel im Park", "Kraftreaktor", "Seilbahn Weissenstein", "Grand Casino Baden", "Pathé", "Cinema Excelsior", "Koi-Breeder", "OTT-Feuerwerk"]},
   330|    {"id": "p16_health_medical", "name": "Gesundheit / Medizin", "category": "Gesundheit / Medizin", "classification": "health_medical", "confidence": "0.88", "patterns": ["Kantonsspital Baden", "KSB", "Universitätsspital Zürich", "Spital Bulach", "Spital Wil", "Apotheke Suessbach", "Vindonissa Rotpunkt", "Loewen Apotheke Frick", "Rotpunkt Apotheken", "Storchen Apotheke", "Apotheke Tag", "Analytica Med", "Laboratori", "Stiftung Gesundheit", "iHerb"]},
   331|    {"id": "p16_auto_transport", "name": "Auto / Transport", "category": "Auto / Transport", "classification": "auto_transport", "confidence": "0.87", "patterns": ["Agrola", "Migrol", "Tamoil", "AVIA", "Esso", "Royal Dutch Shell", "Shell", "Voegtlin-Meyer", "Tankstelle Gebr. Knecht", "Tank-Rastanlage Neckarbur", "Spital Bulach - Parking", "ParkplatzAnnerstrasse", "Parkplatz Rosengarten", "City Parkhaus Zürich", "Parkhaus Trafo Baden", "EasyPark", "Eisi Parkhaus", "Parkhaus Messe Zürich", "Parking Gais-Center", "Mobility Hub Parkservice", "Parkingpay", "SBB CFF FFS", "SBB P+Rail Turgi", "Flughafen Zürich", "Palma de Mallorca Airport", "Volvo Car Corporation"]},
   332|    {"id": "p16_shopping", "name": "Shopping / Kleidung & Elektronik", "category": "Shopping / Kleidung & Elektronik", "classification": "shopping", "confidence": "0.84", "patterns": ["Apfelkiste", "Apple", "MediaMarkt", "H&M", "C&A", "VAN GRAAF", "Claire's", "Dosenbach", "Vero Moda", "OLYMP Digital", "Decathlon", "Smyths Toys", "AliExpress"]},
   333|    {"id": "p16_pets", "name": "Haustiere", "category": "Haustiere", "classification": "pets", "confidence": "0.89", "patterns": ["QUALIPET", "Fressnapf", "Kleintierpraxis Tim"]},
   334|    {"id": "p16_admin_other", "name": "Sonstiges / Administration", "category": "Sonstiges / Administration", "classification": "admin_other", "confidence": "0.76", "patterns": ["Finanzverw.des Kt Schaffh", "Gemeinde Windisch", "Gemeindeverw. Spreitenbac", "Regionalpolizei Brugg", "Dropbox", "Norton", "2CO.COM", "BITDEFENDER", "Bitdefender", "Alpha Progression", "Solar Manager AG", "TradingView", "Ricardo", "Beck-Mobile", "Eventlogistik-Armyliq", "Payment Solution & Servic", "SegPay.com", "Sami International"]},
   335|]
   336|
   337|GALAXUS_DIGITEC_RULE = {"id": "p16_galaxus_digitec_review", "name": "Galaxus/Digitec manuell prüfen oder splitten", "category": "Shopping / Kleidung & Elektronik", "classification": "galaxus_review", "confidence": "0.72", "patterns": ["Digitec Galaxus AG", "Galaxus", "Digitec"]}
   338|
   339|
   340|def _is_primary_migros_card_text(text: str) -> bool:
   341|    hay = _normalise(text)
   342|    if "migrol" in hay:
   343|        return False
   344|    return any(p in hay for p in ["migros", "cumulus", "mmm "])
   345|
   346|
   347|def _phase16_match(text: str) -> dict[str, Any] | None:
   348|    if _matches_any(text, GALAXUS_DIGITEC_RULE["patterns"]):
   349|        return GALAXUS_DIGITEC_RULE
   350|    for rule in PHASE16_RULES:
   351|        if _matches_any(text, rule["patterns"]):
   352|            return rule
   353|    return None
   354|
   355|
   356|def _category_id_name(conn: Connection, name: str) -> tuple[str | None, str]:
   357|    row = conn.execute("SELECT category_id, name FROM budget_categories WHERE is_active=1 AND lower(name)=lower(?) ORDER BY sort_order LIMIT 1", (name,)).fetchone()
   358|    if row:
   359|        return str(row["category_id"]), str(row["name"])
   360|    cid, cname = _category_by_name(conn, [name])
   361|    return cid, cname or name
   362|
   363|
   364|def apply_user_budget_rules_to_candidates(conn: Connection, *, year: str = "2026") -> dict[str, int]:
   365|    rows = conn.execute(
   366|        """
   367|        SELECT * FROM budget_transaction_candidates
   368|        WHERE substr(COALESCE(transaction_date,''),1,4)=?
   369|          AND source_type IN ('credit_card_csv','migros_receipt')
   370|          AND status NOT IN ('confirmed','ignored','superseded')
   371|        ORDER BY created_at
   372|        """,
   373|        (year,),
   374|    ).fetchall()
   375|    counts = {
   376|        "candidate_total": 0, "auto_categorized_count": 0, "review_required_count": 0,
   377|        "galaxus_digitec_review_count": 0, "covered_by_migros_count": 0,
   378|        "migros_under_50_count": 0, "migros_over_50_count": 0, "subscription_count": 0,
   379|        "health_medical_count": 0, "auto_transport_count": 0, "shopping_count": 0,
   380|        "pets_count": 0, "admin_other_count": 0, "unclear_count": 0,
   381|        "productive_confirmed_count": 0,
   382|    }
   383|    changed = 0
   384|    ts = now()
   385|    for row in rows:
   386|        counts["candidate_total"] += 1
   387|        desc = f"{row['merchant'] or ''} {row['description'] or ''}"
   388|        source_type = row["source_type"]
   389|        status = "needs_review"; requires_review = 1; category_id = None; category_name = None
   390|        confidence = "0.35"; classification = "unclear"; review_reason = "Keine sichere Regel gefunden"; rule_id = "p16_unclear"; rule_name = "Unklar · manuelle Prüfung"; covered_by = None
   391|        if source_type == "credit_card_csv" and _is_primary_migros_card_text(desc):
   392|            status = "covered_by_migros"; requires_review = 0; confidence = "0.96"; classification = "covered_by_migros"
   393|            review_reason = "Migros-CSV ist primäre Quelle für Migros-Ausgaben"; rule_id = "p16_card_migros_covered"; rule_name = "Kreditkarten-Migros durch Migros-CSV abgedeckt"; covered_by = "migros_csv"
   394|            counts["covered_by_migros_count"] += 1
   395|        elif source_type == "migros_receipt":
   396|            amount = Decimal(str(row["amount_original"] or "0"))
   397|            category_id, category_name = _category_id_name(conn, "Essen & Haushalt")
   398|            classification = "migros_auto_under_threshold" if amount < Decimal("50") else "migros_review_over_threshold"
   399|            confidence = "0.90" if amount < Decimal("50") else "0.62"
   400|            rule_id = "p16_migros_receipt_under_50" if amount < Decimal("50") else "p16_migros_receipt_over_50"
   401|            rule_name = "Migros Bon unter 50 CHF" if amount < Decimal("50") else "Migros Bon ab 50 CHF"
   402|            review_reason = "Migros Bon unter 50 CHF automatisch Essen & Haushalt" if amount < Decimal("50") else "Migros Bon ab 50 CHF bitte prüfen oder splitten"
   403|            status = "auto_categorized" if amount < Decimal("50") else "needs_review"
   404|            requires_review = 0 if amount < Decimal("50") else 1
   405|            counts["migros_under_50_count" if amount < Decimal("50") else "migros_over_50_count"] += 1
   406|        else:
   407|            rule = _phase16_match(desc)
   408|            if rule:
   409|                category_id, category_name = _category_id_name(conn, str(rule["category"]))
   410|                classification = str(rule["classification"]); confidence = str(rule["confidence"]); rule_id = str(rule["id"]); rule_name = str(rule["name"])
   411|                if rule is GALAXUS_DIGITEC_RULE:
   412|                    status = "needs_review"; requires_review = 1; review_reason = "Galaxus/Digitec bitte manuell prüfen oder splitten"; counts["galaxus_digitec_review_count"] += 1
   413|                elif classification == "admin_other" and Decimal(confidence) < Decimal("0.80"):
   414|                    status = "needs_review"; requires_review = 1; review_reason = "Sonstiges/Administration eher manuell prüfen"
   415|                else:
   416|                    status = "auto_categorized"; requires_review = 0; review_reason = f"Automatischer Vorschlag nach Nutzerregel: {rule_name}"
   417|                if classification == "subscription_auto": counts["subscription_count"] += 1
   418|                if classification == "health_medical": counts["health_medical_count"] += 1
   419|                if classification == "auto_transport": counts["auto_transport_count"] += 1
   420|                if classification == "shopping": counts["shopping_count"] += 1
   421|                if classification == "pets": counts["pets_count"] += 1
   422|                if classification == "admin_other": counts["admin_other_count"] += 1
   423|            else:
   424|                counts["unclear_count"] += 1
   425|        if status == "auto_categorized":
   426|            counts["auto_categorized_count"] += 1
   427|        if requires_review:
   428|            counts["review_required_count"] += 1
   429|        conn.execute(
   430|            """
   431|            UPDATE budget_transaction_candidates
   432|            SET proposed_category_id=?, proposed_category_name=?, confidence=?, requires_review=?, status=?,
   433|                classification=?, review_reason=?, rule_id=?, rule_name=?, covered_by_source=?, updated_at=?
   434|            WHERE transaction_candidate_id=?
   435|            """,
   436|            (category_id, category_name, confidence, requires_review, status, classification, review_reason, rule_id, rule_name, covered_by, ts, row["transaction_candidate_id"]),
   437|        )
   438|        changed += 1
   439|    audit_id = record_audit_event(conn, source="budget_phase16_rules", action="apply_user_budget_rules", entity_type="budget_transaction_candidate", entity_id=f"year:{year}", new_values=counts | {"changed_count": changed}, created_by="agent")
   440|    conn.commit()
   441|    counts["audit_id"] = audit_id  # type: ignore[assignment]
   442|    return counts
   443|
   444|
   445|def seed_credit_card_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, subscription_category_id: str | None = None, migros_covered: bool = True) -> dict[str, int]:
   446|    sub_name = _category_name(conn, subscription_category_id)
   447|    created = subs = galaxus = covered = dup_count = review = ignored = 0
   448|    for idx, raw in enumerate(rows, start=1):
   449|        low = {_normalise(k): v for k, v in raw.items()}
   450|        desc = str(low.get("beschreibung") or low.get("description") or low.get("text") or low.get("merchant") or low.get("merchantname") or low.get("details") or "").strip()
   451|        tx_date = _date_text(low.get("datum") or low.get("date") or low.get("buchungsdatum"))
   452|        amount = _decimal_text(low.get("betrag") or low.get("amount") or low.get("umsatz"))
   453|        if not desc or not amount:
   454|            ignored += 1; continue
   455|        is_sub = _matches_any(desc, SUBSCRIPTION_PATTERNS)
   456|        is_galaxus = _matches_any(desc, GALAXUS_PATTERNS)
   457|        is_migros = _matches_any(desc, MIGROS_PATTERNS)
   458|        dup = _duplicate_of(conn, tx_date, amount, desc)
   459|        status = "pending"; needs_review = False; category_id = None; category_name = None; classification = "card_uncategorized"; reason = "card_review"; conf = "0.40"; covered_by = None
   460|        if is_sub:
   461|            category_id = subscription_category_id; category_name = sub_name; classification = "subscription_auto"; reason = "subscription_merchant_rule"; conf = "0.90"; subs += 1
   462|        if is_migros and migros_covered:
   463|            status = "covered_by_migros"; needs_review = False; classification = "covered_by_migros"; reason = "credit_card_migros_covered_by_migros"; conf = "0.95"; covered_by = "migros_csv"
   464|            covered += 1
   465|        elif dup:
   466|            status = "duplicate"; needs_review = True; reason = "matched_existing_transaction"; dup_count += 1
   467|        elif is_galaxus:
   468|            status = "needs_review"; needs_review = True; classification = "galaxus_review"; reason = "galaxus_requires_user_category_or_split"; conf = "0.70"; galaxus += 1; review += 1
   469|        else:
   470|            status = "needs_review"; needs_review = True; review += 1
   471|        ts = now(); row_ref = f"R{idx}"
   472|        if _insert_candidate(conn, {
   473|            "transaction_candidate_id": "btxcand_" + _candidate_key(source_file_label, row_ref, tx_date, amount, desc),
   474|            "source_file_label": source_file_label, "source_row_or_range": row_ref, "source_type": "credit_card_csv",
   475|            "transaction_date": tx_date, "description": desc[:300], "merchant": desc[:120], "amount_original": amount, "currency_original": "CHF",
   476|            "proposed_category_id": category_id, "proposed_category_name": category_name, "duplicate_of_transaction_id": dup,
   477|            "confidence": conf, "requires_review": needs_review, "status": status, "notes": reason,
   478|            "classification": classification, "review_reason": reason, "rule_id": classification, "source_priority": 70,
   479|            "covered_by_source": covered_by, "raw_fingerprint": _fingerprint([source_file_label, row_ref, tx_date, amount, desc]), "created_at": ts, "updated_at": ts,
   480|        }):
   481|            created += 1
   482|    conn.commit()
   483|    return {"candidate_count": created, "subscription_auto_count": subs, "galaxus_review_count": galaxus, "covered_by_migros_count": covered, "possible_duplicate_count": dup_count, "review_required_count": review, "ignored_count": ignored}
   484|
   485|
   486|@dataclass
   487|class _BankRow:
   488|    idx: int
   489|    date: str | None
   490|    desc: str
   491|    amount_abs: str
   492|    amount_signed: Decimal
   493|    account: str | None
   494|
   495|
   496|def _bank_rows(rows: list[dict[str, Any]]) -> list[_BankRow]:
   497|    parsed = []
   498|    for idx, raw in enumerate(rows, start=1):
   499|        low = {_normalise(k): v for k, v in raw.items()}
   500|        desc = str(low.get("beschreibung") or low.get("description") or low.get("text") or low.get("name") or "").strip()
   501|        date = _date_text(low.get("datum") or low.get("date") or low.get("buchungsdatum"))
   502|        signed = _signed_decimal(low.get("betrag") or low.get("amount") or low.get("umsatz"))
   503|        if not desc or signed is None:
   504|            continue
   505|        parsed.append(_BankRow(idx, date, desc, format(abs(signed), "f"), signed, str(low.get("konto") or low.get("account") or "").strip() or None))
   506|    return parsed
   507|
   508|
   509|def _find_transfer_indices(rows: list[_BankRow]) -> set[int]:
   510|    indices: set[int] = set()
   511|    for i, left in enumerate(rows):
   512|        for right in rows[i + 1:]:
   513|            if left.amount_abs != right.amount_abs or left.amount_signed * right.amount_signed >= 0:
   514|                continue
   515|            if left.date and right.date:
   516|                d1 = datetime.fromisoformat(left.date); d2 = datetime.fromisoformat(right.date)
   517|                if abs((d1 - d2).days) > 3:
   518|                    continue
   519|            joined = f"{left.desc} {right.desc} {left.account or ''} {right.account or ''}"
   520|            if _matches_any(joined, TRANSFER_PATTERNS):
   521|                indices.add(left.idx); indices.add(right.idx)
   522|    return indices
   523|
   524|
   525|def seed_bank_transfer_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, income_category_id: str | None = None) -> dict[str, int]:
   526|    parsed = _bank_rows(rows)
   527|    transfer_indices = _find_transfer_indices(parsed)
   528|    income_name = _category_name(conn, income_category_id)
   529|    created = income = transfer = review = dup_count = 0
   530|    for br in parsed:
   531|        dup = _duplicate_of(conn, br.date, br.amount_abs, br.desc)
   532|        category_id = None; category_name = None; status = "needs_review"; needs_review = True; classification = "bank_review"; reason = "bank_payment_unclear"; tx_type = "expense" if br.amount_signed < 0 else "income"; conf = "0.50"
   533|        if br.idx in transfer_indices:
   534|            status = "transfer_candidate"; needs_review = True; classification = "transfer_candidate"; reason = "possible_internal_transfer"; tx_type = "transfer"; conf = "0.85"; transfer += 1
   535|        elif br.amount_signed > 0 and _matches_any(br.desc, INCOME_PATTERNS):
   536|            category_id = income_category_id; category_name = income_name; status = "pending"; needs_review = False; classification = "income_candidate"; reason = "income_pattern"; tx_type = "income"; conf = "0.85"; income += 1
   537|        elif dup:
   538|            status = "duplicate"; classification = "possible_duplicate"; reason = "matched_existing_transaction"; dup_count += 1
   539|        else:
   540|            review += 1
   541|        ts = now(); row_ref = f"R{br.idx}"
   542|        if _insert_candidate(conn, {
   543|            "transaction_candidate_id": "btxcand_" + _candidate_key(source_file_label, row_ref, br.date, br.amount_abs, br.desc),
   544|            "source_file_label": source_file_label, "source_row_or_range": row_ref, "source_type": "bank_csv",
   545|            "transaction_date": br.date, "description": br.desc[:300], "merchant": None, "amount_original": br.amount_abs, "currency_original": "CHF",
   546|            "proposed_category_id": category_id, "proposed_category_name": category_name, "duplicate_of_transaction_id": dup,
   547|            "confidence": conf, "requires_review": needs_review, "status": status, "notes": json.dumps({"transaction_type": tx_type, "reason": reason}, ensure_ascii=False),
   548|            "classification": classification, "review_reason": reason, "rule_id": classification, "source_priority": 80,
   549|            "account_source": br.account, "raw_fingerprint": _fingerprint([source_file_label, row_ref, br.date, br.amount_abs, br.desc, br.account]), "created_at": ts, "updated_at": ts,
   550|        }):
   551|            created += 1
   552|    conn.commit()
   553|    return {"candidate_count": created, "income_candidate_count": income, "transfer_candidate_count": transfer, "review_required_count": review, "possible_duplicate_count": dup_count}
   554|
   555|
   556|def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False) -> list[dict[str, Any]]:
   557|    clauses=[]; params=[]
   558|    if budget_year:
   559|        clauses.append("substr(transaction_date,1,4)=?"); params.append(budget_year)
   560|    if status:
   561|        clauses.append("status=?"); params.append(status)
   562|    elif not include_reference and not tab:
   563|        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','superseded','reference_2025','archived_reference')")
   564|    if tab:
   565|        mapping = {
   566|            "auto": "requires_review=0 AND status IN ('pending','auto_categorized')",
   567|            "review": "requires_review=1 AND status IN ('needs_review','pending','transfer_candidate','duplicate','duplicate_candidate')",
   568|            "subscriptions": "classification='subscription_auto'",
   569|            "galaxus": "classification='galaxus_review'",
   570|            "migros_over_50": "classification='migros_review_over_threshold'",
   571|            "health": "classification='health_medical'",
   572|            "auto_transport": "classification='auto_transport'",
   573|            "shopping": "classification IN ('shopping','galaxus_review')",
   574|            "pets": "classification='pets'",
   575|            "unclear": "classification IN ('admin_other','unclear','legacy_csv_seed','card_uncategorized')",
   576|            "covered_by_migros": "status='covered_by_migros'",
   577|            "duplicates": "status IN ('duplicate','duplicate_candidate')",
   578|            "transfers": "classification='transfer_candidate' OR status='transfer_candidate'",
   579|            "bank": "source_type='bank_csv'",
   580|            "income": "classification='income_candidate'",
   581|            "ignored": "status IN ('ignored','covered_by_migros','superseded','reference_2025','archived_reference')",
   582|        }
   583|        if tab in mapping:
   584|            clauses.append(mapping[tab])
   585|    if source_type:
   586|        clauses.append("source_type=?"); params.append(source_type)
   587|    if category_id:
   588|        clauses.append("proposed_category_id=?"); params.append(category_id)
   589|    if merchant:
   590|        clauses.append("lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"); params.append(f"%{merchant.lower()}%")
   591|    if rule:
   592|        clauses.append("lower(COALESCE(rule_id,'') || ' ' || COALESCE(rule_name,'') || ' ' || COALESCE(review_reason,'')) LIKE ?"); params.append(f"%{rule.lower()}%")
   593|    if min_confidence:
   594|        clauses.append("CAST(COALESCE(confidence,'0') AS REAL) >= CAST(? AS REAL)"); params.append(min_confidence)
   595|    if date_from:
   596|        clauses.append("transaction_date>=?"); params.append(date_from)
   597|

Use offset=597 to continue reading (showing 237-596 of 1033 lines)


   597|    if date_to:
   598|        clauses.append("transaction_date<=?"); params.append(date_to)
   599|    if amount_min:
   600|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) >= CAST(? AS REAL)"); params.append(amount_min)
   601|    if amount_max:
   602|        clauses.append("CAST(COALESCE(amount_original,'0') AS REAL) <= CAST(? AS REAL)"); params.append(amount_max)
   603|    where = "WHERE " + " AND ".join(clauses) if clauses else ""
   604|    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"}
   605|    order_col = sort_map.get(sort_by, "created_at")
   606|    direction = "ASC" if sort_dir.lower() == "asc" else "DESC"
   607|    rows = conn.execute(f"SELECT * FROM budget_transaction_candidates {where} ORDER BY {order_col} {direction}, created_at DESC LIMIT 500", params).fetchall()
   608|    return [row_to_dict(r) | {"requires_review": bool(r["requires_review"])} for r in rows]
   609|
   610|
   611|def preview_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
   612|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   613|    if not row:
   614|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   615|    payload = payload or {}
   616|    cat_id = payload.get("category_id") or row["proposed_category_id"]
   617|    notes = row["notes"] or ""
   618|    tx_type = payload.get("transaction_type") or "expense"
   619|    try:
   620|        meta = json.loads(notes) if notes and notes.startswith("{") else {}
   621|        tx_type = payload.get("transaction_type") or meta.get("transaction_type") or tx_type
   622|    except json.JSONDecodeError:
   623|        pass
   624|    if not cat_id and tx_type in {"expense", "income"}:
   625|        raise HTTPException(status_code=422, detail="category required before confirm")
   626|    if cat_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat_id,)).fetchone():
   627|        raise HTTPException(status_code=422, detail="active category required before confirm")
   628|    if not payload.get("account_id"):
   629|        raise HTTPException(status_code=422, detail="account_id required before confirm")
   630|    if not row["transaction_date"]:
   631|        raise HTTPException(status_code=422, detail="transaction date required before confirm")
   632|    if not row["amount_original"]:
   633|        raise HTTPException(status_code=422, detail="amount required before confirm")
   634|    if not row["currency_original"]:
   635|        raise HTTPException(status_code=422, detail="currency required before confirm")
   636|    return {
   637|        "preview_id": new_id("preview"),
   638|        "summary": "Buchungskandidat übernehmen",
   639|        "warnings": ["Möglicher Duplikat-Treffer"] if row["duplicate_of_transaction_id"] else ([] if cat_id or tx_type == "transfer" else ["Kategorie fehlt"]),
   640|        "payload": {
   641|            "account_id": payload.get("account_id"),
   642|            "transaction_type": tx_type,
   643|            "transaction_date": payload.get("transaction_date") or row["transaction_date"],
   644|            "description": payload.get("description") or row["description"],
   645|            "payee": payload.get("payee") or row["merchant"],
   646|            "amount_original": row["amount_original"],
   647|            "currency_original": row["currency_original"],
   648|            "category_id": cat_id,
   649|            "source_type": "import_candidate",
   650|            "source_candidate_id": candidate_id,
   651|            "notes": f"Confirmed from transaction candidate {candidate_id}",
   652|        },
   653|    }
   654|
   655|
   656|def confirm_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   657|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   658|    if not row:
   659|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   660|    if row["status"] == "confirmed":
   661|        raise HTTPException(status_code=409, detail="candidate already confirmed")
   662|    if row["status"] in {"covered_by_migros", "superseded", "ignored", "reference_2025", "archived_reference"}:
   663|        raise HTTPException(status_code=409, detail="candidate is not confirmable")
   664|    if str(row["transaction_date"] or "")[:4] != str(payload.get("budget_year") or "2026"):
   665|        raise HTTPException(status_code=409, detail="candidate outside active budget year")
   666|    preview = preview_transaction_candidate(conn, candidate_id, payload)
   667|    result = confirm_budget_transaction(conn, preview["payload"])
   668|    ts = now()
   669|    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))
   670|    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")
   671|    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")
   672|    conn.commit()
   673|    return {"status": "confirmed", "entity_id": result["entity_id"], "audit_id": audit_id, "message": "Buchungskandidat übernommen"}
   674|
   675|
   676|def ignore_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   677|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   678|    if not row:
   679|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   680|    ts = now()
   681|    conn.execute("UPDATE budget_transaction_candidates SET status='ignored', notes=COALESCE(?, notes), updated_at=? WHERE transaction_candidate_id=?", (note, ts, candidate_id))
   682|    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")
   683|    conn.commit()
   684|    return {"status": "ignored", "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat ignoriert"}
   685|
   686|
   687|def reopen_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   688|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   689|    if not row:
   690|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   691|    if row["status"] == "confirmed":
   692|        raise HTTPException(status_code=409, detail="confirmed candidates cannot be reopened")
   693|    new_status = "needs_review" if row["requires_review"] else "pending"
   694|    ts = now()
   695|    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))
   696|    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")
   697|    conn.commit()
   698|    return {"status": new_status, "entity_id": candidate_id, "audit_id": audit_id, "message": "Buchungskandidat wieder geöffnet"}
   699|
   700|
   701|def update_transaction_candidate_category(conn: Connection, candidate_id: str, category_id: str, note: str | None = None) -> dict[str, Any]:
   702|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   703|    if not row:
   704|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   705|    if not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (category_id,)).fetchone():
   706|        raise HTTPException(status_code=422, detail="active category not found")
   707|    cname = _category_name(conn, category_id)
   708|    ts = now()
   709|    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))
   710|    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")
   711|    conn.commit()
   712|    return {"status": "updated", "entity_id": candidate_id, "audit_id": audit_id}
   713|
   714|
   715|def create_candidate_split(conn: Connection, candidate_id: str, splits: list[dict[str, Any]]) -> dict[str, Any]:
   716|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   717|    if not row:
   718|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   719|    if not splits:
   720|        raise HTTPException(status_code=422, detail="splits required")
   721|    ts = now()
   722|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
   723|    for split in splits:
   724|        amount = _decimal_text(split.get("amount_original"))
   725|        if not amount:
   726|            raise HTTPException(status_code=422, detail="split amount required")
   727|        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))
   728|    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))
   729|    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")
   730|    conn.commit()
   731|    return {"status": "split_created", "entity_id": candidate_id, "split_count": len(splits), "audit_id": audit_id}
   732|
   733|
   734|def mark_candidate_transfer(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
   735|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   736|    if not row:
   737|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   738|    ts = now()
   739|    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))
   740|    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")
   741|    conn.commit()
   742|    return {"status": "transfer_candidate", "entity_id": candidate_id, "audit_id": audit_id}
   743|
   744|
   745|def bulk_update_transaction_candidate_category(conn: Connection, *, candidate_ids: list[str], category_id: str, note: str | None = None) -> dict[str, int]:
   746|    updated = skipped = 0
   747|    for cid in candidate_ids:
   748|        row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   749|        if not row or row["status"] == "confirmed":
   750|            skipped += 1
   751|            continue
   752|        update_transaction_candidate_category(conn, cid, category_id, note or "Bulk category assignment")
   753|        updated += 1
   754|    return {"updated_count": updated, "skipped_count": skipped}
   755|
   756|
   757|def _safe_auto_candidate_where() -> str:
   758|    return """
   759|        status='pending'
   760|        AND COALESCE(requires_review, 0)=0
   761|        AND duplicate_of_transaction_id IS NULL
   762|        AND COALESCE(covered_by_source, '')=''
   763|        AND (
   764|            classification='subscription_auto'
   765|            OR (classification='migros_auto_under_threshold' AND CAST(COALESCE(amount_original, '999999') AS REAL) < 50)
   766|            OR classification='auto_transport'
   767|        )
   768|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%galaxus%'
   769|        AND lower(COALESCE(description,'') || ' ' || COALESCE(merchant,'')) NOT LIKE '%digitec%'
   770|    """
   771|
   772|
   773|def preview_safe_auto_candidates(conn: Connection) -> dict[str, Any]:
   774|    rows = conn.execute(
   775|        f"""
   776|        SELECT classification, COUNT(*) AS count
   777|        FROM budget_transaction_candidates
   778|        WHERE {_safe_auto_candidate_where()}
   779|        GROUP BY classification
   780|        ORDER BY classification
   781|        """
   782|    ).fetchall()
   783|    total = sum(int(r["count"] or 0) for r in rows)
   784|    return {
   785|        "preview_id": new_id("preview"),
   786|        "summary": f"{total} sichere Auto-Kandidaten bereit für Confirm",
   787|        "count": total,
   788|        "groups": [row_to_dict(r) for r in rows],
   789|        "warnings": ["Preview only: Confirm benötigt Zielkonto und bucht nur sichere Kandidaten."],
   790|    }
   791|
   792|
   793|def batch_confirm_safe_candidates(conn: Connection, *, account_id: str, candidate_ids: list[str] | None = None) -> dict[str, int]:
   794|    if not account_id:
   795|        raise HTTPException(status_code=422, detail="account_id required")
   796|    allowed_rows = conn.execute(f"SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE {_safe_auto_candidate_where()}").fetchall()
   797|    allowed_ids = {str(r["transaction_candidate_id"]) for r in allowed_rows}
   798|    requested_ids = set(candidate_ids or allowed_ids)
   799|    confirmed = skipped = 0
   800|    for cid in sorted(requested_ids):
   801|        if cid not in allowed_ids:
   802|            skipped += 1
   803|            continue
   804|        confirm_transaction_candidate(conn, cid, {"account_id": account_id})
   805|        confirmed += 1
   806|    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")
   807|    conn.commit()
   808|    return {"confirmed_count": confirmed, "skipped_count": skipped}
   809|
   810|
   811|def review_candidate_summary(conn: Connection, *, budget_year: str = "2026") -> dict[str, int]:
   812|    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()
   813|    def count(pred):
   814|        return sum(1 for r in rows if pred(r))
   815|    return {
   816|        "open_total": count(lambda r: r["status"] != "covered_by_migros"),
   817|        "auto_categorized": count(lambda r: r["status"] in {"pending", "auto_categorized"} and not bool(r["requires_review"])),
   818|        "review_needed": count(lambda r: bool(r["requires_review"])),
   819|        "galaxus_digitec": count(lambda r: r["classification"] == "galaxus_review"),
   820|        "migros_over_50": count(lambda r: r["classification"] == "migros_review_over_threshold"),
   821|        "covered_by_migros": count(lambda r: r["status"] == "covered_by_migros"),
   822|        "unclear": count(lambda r: r["classification"] in {"admin_other", "unclear", "legacy_csv_seed", "card_uncategorized"}),
   823|        "possible_duplicates": count(lambda r: r["status"] in {"duplicate", "duplicate_candidate"} or bool(r["duplicate_of_transaction_id"])),
   824|    }
   825|
   826|
   827|def preview_batch_confirm_candidates(conn: Connection, *, candidate_ids: list[str], account_id: str, budget_year: str = "2026") -> dict[str, Any]:
   828|    rows = []
   829|    conflicts = []
   830|    categories: dict[str, int] = {}
   831|    for cid in candidate_ids:
   832|        row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   833|        if not row:
   834|            conflicts.append({"candidate_id": cid, "reason": "missing"}); continue
   835|        if row["status"] in {"confirmed", "ignored", "covered_by_migros", "superseded", "reference_2025", "archived_reference"}:
   836|            conflicts.append({"candidate_id": cid, "reason": f"not_confirmable:{row['status']}"}); continue
   837|        if str(row["transaction_date"] or "")[:4] != str(budget_year):
   838|            conflicts.append({"candidate_id": cid, "reason": "outside_active_budget_year"}); continue
   839|        try:
   840|            preview_transaction_candidate(conn, cid, {"account_id": account_id, "category_id": row["proposed_category_id"], "budget_year": budget_year})
   841|        except Exception as exc:
   842|            conflicts.append({"candidate_id": cid, "reason": str(exc)}); continue
   843|        rows.append(row)
   844|        categories[str(row["proposed_category_name"] or "Review nötig")] = categories.get(str(row["proposed_category_name"] or "Review nötig"), 0) + 1
   845|    if not rows and conflicts:
   846|        raise HTTPException(status_code=409, detail={"message": "no confirmable candidates", "conflicts": conflicts})
   847|    preview_id = new_id("batchprev")
   848|    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")
   849|    conn.commit()
   850|    return {"preview_id": preview_id, "count": len(rows), "categories": categories, "conflicts": conflicts, "requires_explicit_confirm": True}
   851|
   852|
   853|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]:
   854|    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": []}
   855|    if action == "confirm" and not preview_id:
   856|        raise HTTPException(status_code=422, detail="batch confirm requires preview_id")
   857|    for cid in candidate_ids:
   858|        try:
   859|            row_for_year = conn.execute("SELECT transaction_date FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   860|            if not row_for_year:
   861|                raise HTTPException(status_code=404, detail="candidate not found")
   862|            if str(row_for_year["transaction_date"] or "")[:4] != str(budget_year):
   863|                raise HTTPException(status_code=409, detail="candidate outside active budget year")
   864|            if action == "category":
   865|                if not category_id: raise HTTPException(status_code=422, detail="category_id required")
   866|                update_transaction_candidate_category(conn, cid, category_id, note or "Batch category assignment"); counts["updated_count"] += 1
   867|            elif action == "confirm":
   868|                confirm_transaction_candidate(conn, cid, {"account_id": account_id, "budget_year": budget_year}); counts["confirmed_count"] += 1
   869|            elif action == "ignore":
   870|                ignore_transaction_candidate(conn, cid, note or "Batch ignore"); counts["ignored_count"] += 1
   871|            elif action == "reopen":
   872|                reopen_transaction_candidate(conn, cid, note or "Batch reopen"); counts["reopened_count"] += 1
   873|            elif action == "transfer":
   874|                mark_candidate_transfer(conn, cid, note or "Batch transfer"); counts["transfer_count"] += 1
   875|            elif action == "covered_by_migros":
   876|                ts = now(); row = conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (cid,)).fetchone()
   877|                if not row: raise HTTPException(status_code=404, detail="candidate not found")
   878|                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))
   879|                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")
   880|                counts["covered_by_migros_count"] += 1
   881|            else:
   882|                raise HTTPException(status_code=422, detail="invalid batch action")
   883|        except Exception as exc:
   884|            counts["skipped_count"] += 1
   885|            detail = getattr(exc, "detail", str(exc))
   886|            counts["errors"].append({"candidate_id": cid, "error": str(detail)})
   887|    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")
   888|    conn.commit()
   889|    return counts | {"audit_id": audit_id}
   890|
   891|
   892|def confirm_candidate_split(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   893|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   894|    if not row:
   895|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   896|    if row["status"] in {"confirmed", "ignored", "covered_by_migros", "superseded"}:
   897|        raise HTTPException(status_code=409, detail="candidate is not splittable")
   898|    splits = payload.get("splits") or []
   899|    if not payload.get("account_id"):
   900|        raise HTTPException(status_code=422, detail="account_id required")
   901|    if not splits:
   902|        raise HTTPException(status_code=422, detail="splits required")
   903|    total = sum(Decimal(_decimal_text(s.get("amount_original")) or "0") for s in splits)
   904|    candidate_amount = Decimal(str(row["amount_original"] or "0"))
   905|    if total != candidate_amount:
   906|        raise HTTPException(status_code=422, detail="split sum must match candidate amount")
   907|    group_id = new_id("splitgrp")
   908|    ts = now()
   909|    created_ids = []
   910|    conn.execute("DELETE FROM budget_candidate_splits WHERE transaction_candidate_id=?", (candidate_id,))
   911|    for idx, split in enumerate(splits, start=1):
   912|        amount = _decimal_text(split.get("amount_original"))
   913|        cat = split.get("category_id")
   914|        if not cat or not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat,)).fetchone():
   915|            raise HTTPException(status_code=422, detail="active split category required")
   916|        result = confirm_budget_transaction(conn, {
   917|            "account_id": payload["account_id"], "transaction_type": payload.get("transaction_type") or "expense", "transaction_date": row["transaction_date"],
   918|            "description": f"{row['description']} · Split {idx}", "amount_original": amount, "currency_original": row["currency_original"],
   919|            "category_id": cat, "source_type": "import_candidate", "source_candidate_id": candidate_id,
   920|            "notes": f"Split group {group_id}: {split.get('note') or ''}", "tag_names": [split.get("tag")] if split.get("tag") else [],
   921|        })
   922|        created_ids.append(result["entity_id"])
   923|        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))
   924|    primary_confirmed_id = created_ids[0] if created_ids else group_id
   925|    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))
   926|    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")
   927|    conn.commit()
   928|    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}
   929|
   930|
   931|def create_review_category_for_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
   932|    if not conn.execute("SELECT 1 FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone():
   933|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   934|    name = str(payload.get("name") or "").strip()
   935|    if not name:
   936|        raise HTTPException(status_code=422, detail="category name required")
   937|    ts = now(); category_id = new_id("bcat")
   938|    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))
   939|    update_transaction_candidate_category(conn, candidate_id, category_id, "Created from review")
   940|    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")
   941|    conn.commit()
   942|    return {"status": "created", "category_id": category_id, "audit_id": audit_id}
   943|
   944|
   945|def create_budget_rule(conn: Connection, payload: dict[str, Any]) -> dict[str, Any]:
   946|    text = str(payload.get("merchant_contains") or "").strip()
   947|    if not text:
   948|        raise HTTPException(status_code=422, detail="merchant_contains required")
   949|    target_status = payload.get("target_status") or "review"
   950|    if target_status not in {"auto", "review"}:
   951|        raise HTTPException(status_code=422, detail="invalid target_status")
   952|    rule_id = new_id("brule"); ts = now()
   953|    conn.execute("INSERT INTO budget_review_rules(rule_id, merchant_contains, source_type, category_id, target_status, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?)", (rule_id, text, payload.get("source_type") or None, payload.get("category_id") or None, target_status, ts, ts))
   954|    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}, created_by="user")
   955|    conn.commit()
   956|    return {"status": "created", "rule_id": rule_id, "audit_id": audit_id}
   957|
   958|
   959|def list_budget_rules(conn: Connection, *, include_inactive: bool = False) -> list[dict[str, Any]]:
   960|    where = "" if include_inactive else "WHERE r.is_active=1"
   961|    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.created_at DESC").fetchall()
   962|    return [row_to_dict(r) | {"is_active": bool(r["is_active"])} for r in rows]
   963|
   964|
   965|def _rule_match_rows(conn: Connection, rule_id: str):
   966|    rule = conn.execute("SELECT * FROM budget_review_rules WHERE rule_id=?", (rule_id,)).fetchone()
   967|    if not rule:
   968|        raise HTTPException(status_code=404, detail="rule not found")
   969|    clauses = ["status NOT IN ('confirmed','ignored','covered_by_migros','superseded')", "lower(COALESCE(merchant,'') || ' ' || COALESCE(description,'')) LIKE ?"]
   970|    params: list[Any] = [f"%{str(rule['merchant_contains']).lower()}%"]
   971|    if rule["source_type"]:
   972|        clauses.append("source_type=?"); params.append(rule["source_type"])
   973|    return rule, conn.execute("SELECT * FROM budget_transaction_candidates WHERE " + " AND ".join(clauses), params).fetchall()
   974|
   975|
   976|def test_budget_rule(conn: Connection, rule_id: str) -> dict[str, Any]:
   977|    rule, rows = _rule_match_rows(conn, rule_id)
   978|    return {"rule_id": rule_id, "match_count": len(rows), "would_set_category": rule["category_id"], "would_set_status": rule["target_status"]}
   979|
   980|
   981|def apply_budget_rules_to_open_candidates(conn: Connection, rule_id: str) -> dict[str, Any]:
   982|    rule, rows = _rule_match_rows(conn, rule_id)
   983|    if not rule["is_active"]:
   984|        raise HTTPException(status_code=409, detail="rule inactive")
   985|    status = "auto_categorized" if rule["target_status"] == "auto" else "needs_review"
   986|    requires_review = 0 if rule["target_status"] == "auto" else 1
   987|    cname = _category_name(conn, rule["category_id"])
   988|    ts = now(); updated = 0
   989|    for row in rows:
   990|        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='0.82', updated_at=? WHERE transaction_candidate_id=?", (rule["category_id"], cname, status, requires_review, rule_id, f"Regel: enthält {rule['merchant_contains']}", ts, row["transaction_candidate_id"]))
   991|        updated += 1
   992|    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")
   993|    conn.commit()
   994|    return {"updated_count": updated, "audit_id": audit_id, "productive_transaction_count": 0}
   995|
   996|
   997|def deactivate_budget_rule(conn: Connection, rule_id: str) -> dict[str, Any]:
   998|    row = conn.execute("SELECT is_active FROM budget_review_rules WHERE rule_id=?", (rule_id,)).fetchone()
   999|    if not row:
  1000|        raise HTTPException(status_code=404, detail="rule not found")
  1001|    ts = now(); conn.execute("UPDATE budget_review_rules SET is_active=0, updated_at=? WHERE rule_id=?", (ts, rule_id))
  1002|    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")
  1003|    conn.commit(); return {"status": "deactivated", "rule_id": rule_id, "audit_id": audit_id}
  1004|
  1005|
  1006|def archive_reference_year_candidates(conn: Connection, *, active_year: str = "2026", reference_year: str = "2025") -> dict[str, int | str]:
  1007|    ts = now()
  1008|    rows = conn.execute("SELECT * FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirmed','reference_2025','archived_reference','superseded')", (reference_year,)).fetchall()
  1009|    updated = 0
  1010|    for row in rows:
  1011|        status = f"reference_{reference_year}" if reference_year == "2025" else "archived_reference"
  1012|        conn.execute("UPDATE budget_transaction_candidates SET status=?, requires_review=0, review_reason=?, updated_at=? WHERE transaction_candidate_id=?", (status, f"reference_year_not_active_budget_{active_year}", ts, row["transaction_candidate_id"]))
  1013|        record_audit_event(conn, source="phase110_cleanup", action="archive_reference_year_candidate", entity_type="budget_transaction_candidate", entity_id=row["transaction_candidate_id"], old_values=row_to_dict(row), new_values={"status": status, "active_year": active_year}, created_by="system")
  1014|        updated += 1
  1015|    conn.commit()
  1016|    return {"updated_count": updated, "active_year": active_year, "reference_year": reference_year}
  1017|
  1018|
  1019|def cleanup_migros_article_candidates(conn: Connection, *, dry_run: bool = True) -> dict[str, int]:
  1020|    where = """
  1021|        status IN ('pending','needs_review','duplicate')
  1022|        AND (lower(COALESCE(source_file_label,'')) LIKE '%migros%' OR lower(COALESCE(source_file_label,'')) LIKE '%cumulus%')
  1023|        AND COALESCE(source_type,'') NOT IN ('migros_receipt')
  1024|    """
  1025|    rows = conn.execute(f"SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE {where}").fetchall()
  1026|    count = len(rows)
  1027|    if dry_run:
  1028|        return {"article_candidate_count": count, "superseded_count": 0, "dry_run": 1}
  1029|    ts = now()
  1030|    conn.execute(f"UPDATE budget_transaction_candidates SET status='superseded', review_reason='superseded_by_migros_receipt_aggregation', updated_at=? WHERE {where}", (ts,))
  1031|    audit_id = record_audit_event(conn, source="budget_phase15_cleanup", action="cleanup_migros_article_candidates", entity_type="budget_transaction_candidate", entity_id="migros_article_candidates", new_values={"article_candidate_count": count, "status": "superseded"}, created_by="agent")
  1032|    conn.commit()
  1033|    return {"article_candidate_count": count, "superseded_count": count, "audit_id": audit_id, "dry_run": 0}
  1034|


Explanation:
**Searching for tests**

I need to find some tests and gather information on the schema and fixtures related to them. First, I should clarify what specific tests I’m looking for—maybe some functionality or unit tests? Then, I’ll search to see if there are existing tests available for my purposes. It’s important to ensure I have the right schema information incorporated into the tests as well. Let’s make sure I cover everything needed!


     1|import { apiGet, apiPost } from './client'
     2|
     3|export type BudgetAccount = { budget_account_id: string; name: string; account_type: string; currency: string; is_active: boolean; linked_account_id?: string | null; archived_at?: string | null; notes?: string | null }
     4|export type BudgetCategory = { category_id: string; parent_category_id?: string | null; name: string; category_type: string; is_active: boolean; color?: string | null; icon?: string | null; transaction_count?: number; expense_sum_chf?: string | number; active_plan_count?: number; children?: BudgetCategory[] }
     5|export type BudgetTag = { tag_id: string; name: string; color?: string | null; is_active: boolean }
     6|export type BudgetTransaction = { budget_transaction_id: string; account_id?: string; account_name?: string; category_id?: string | null; category_name?: string | null; transaction_date: string; transaction_type: string; description: string; amount_original: string; currency_original: string; amount_chf?: string | null; fx_status?: string; source_type?: string; status: string }
     7|export type BudgetPreview = { preview_id: string; summary: string; fx_status?: string; warnings: string[]; payload: Record<string, unknown> }
     8|export type BudgetConfirm = { status: string; entity_id: string; audit_id: string; message: string }
     9|export type BudgetSeedCandidate = {
    10|  seed_candidate_id: string
    11|  source_file_label?: string
    12|  source_sheet: string
    13|  source_row_or_range?: string | null
    14|  candidate_type: 'category' | 'budget_plan' | 'recurring_candidate' | 'unclean_range'
    15|  source_label?: string | null
    16|  proposed_category_id?: string | null
    17|  proposed_parent_label?: string | null
    18|  proposed_name?: string | null
    19|  proposed_period_type?: string | null
    20|  proposed_amount_text?: string | null
    21|  currency?: string | null
    22|  confidence: string
    23|  requires_review: boolean
    24|  status: string
    25|  notes?: string | null
    26|}
    27|export type BudgetSeedSummary = { category_candidate_count: number; budget_plan_candidate_count: number; recurring_candidate_count: number; unclean_range_count: number; current_budget_sheet?: string }
    28|export type BudgetStatusRow = { category_id: string; category: string; category_type: string; budget_month?: string | null; budget_year?: string | null; has_budget?: boolean; actual_year_to_date: string; monthly_actuals?: Record<string, string>; actual_avg_month: string; forecast_year: string; forecast_avg_month: string; deviation_year: string; deviation_month: string; status: string; status_hint?: string; months_with_bookings: number; plan_names?: string[]; is_rollup?: boolean; rollup_child_count?: number }
    29|export type BudgetTransactionCandidate = { transaction_candidate_id: string; source_file_label: string; source_row_or_range?: string | null; source_type: string; transaction_date?: string | null; description: string; merchant?: string | null; amount_original?: string | null; currency_original: string; proposed_category_id?: string | null; proposed_category_name?: string | null; duplicate_of_transaction_id?: string | null; confidence: string; requires_review: boolean; status: string; notes?: string | null; classification?: string | null; review_reason?: string | null; rule_id?: string | null; rule_name?: string | null; account_source?: string | null; covered_by_source?: string | null }
    30|export type BudgetReviewSummary = { open_total: number; auto_categorized: number; review_needed: number; galaxus_digitec: number; migros_over_50: number; covered_by_migros: number; unclear: number; possible_duplicates: number }
    31|export type BudgetRule = { rule_id: string; merchant_contains: string; source_type?: string | null; category_id?: string | null; category_name?: string | null; target_status: string; is_active: boolean }
    32|export type BudgetCharts = { year: string; empty_state: boolean; cashflow_by_month: Array<{ year: string; month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; budget_vs_actual_by_category: Array<{ year: string; category_id: string; category: string; category_type: string; budget_year?: string | null; actual_year_to_date: string; forecast_year: string; status: string }>; forecast_overruns: Array<Record<string, string>> }
    33|export type IncomePlan = { plan_item_id: string; name: string; person_source: string; income_type: string; cadence: string; monthly_value_chf: string; annual_value_chf: string; category_id: string; category_name: string; is_active: boolean; notes?: string | null }
    34|export type IncomeSeedCandidate = { seed_candidate_id: string; candidate_type: string; proposed_name?: string | null; proposed_parent_label?: string | null; confidence: string; requires_review: boolean; status: string; source_sheet?: string; source_row_or_range?: string | null }
    35|export type BudgetOverview = { current_month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string; review_count: number; uncategorized_count: number; pending_seed_candidate_count?: number; pending_transaction_candidate_count?: number; planned_income_chf?: string; planned_expense_chf?: string; planned_net_chf?: string; actual_income_chf?: string; actual_expense_chf: string; actual_net_chf?: string; actuals_label?: string; open_categories_without_actuals?: number; planned_budget_chf: string; remaining_budget_chf: string; budget_status: string; budget_categories: BudgetStatusRow[]; uncategorized_transactions: BudgetTransaction[]; top_categories: Array<{ category_id?: string; category_name: string; amount_chf: string; transaction_count?: number }>; recent_transactions: BudgetTransaction[] }
    36|
    37|export const getBudgetOverview = (month = '2026-01') => apiGet<BudgetOverview>(`/api/budget/overview?month=${month}`)
    38|export const getBudgetStatusByCategory = (year = '2026') => apiGet<BudgetStatusRow[]>(`/api/budget/status/categories?year=${year}`)
    39|export const getBudgetCharts = (year = '2026') => apiGet<BudgetCharts>(`/api/budget/charts?year=${year}`)
    40|export const getIncomeStatus = (year = '2026') => apiGet<Record<string, unknown>>(`/api/budget/income/status?year=${year}`)
    41|export const getIncomePlans = (year = '2026') => apiGet<IncomePlan[]>(`/api/budget/income/plans?year=${year}`)
    42|export const getIncomeSeedCandidates = (year = '2026') => apiGet<IncomeSeedCandidate[]>(`/api/budget/income/seed-candidates?year=${year}`)
    43|export const previewIncomePlan = (body: unknown) => apiPost<BudgetPreview>('/api/budget/income/plans/preview', body)
    44|export const confirmIncomePlan = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/income/plans/confirm', body)
    45|export const archiveIncomePlan = (id: string) => apiPost<BudgetConfirm>(`/api/budget/income/plans/${id}/archive`, {})
    46|export const getBudgetTransactions = (query = '') => apiGet<BudgetTransaction[]>(`/api/budget/transactions${query}`)
    47|export const getBudgetAccounts = () => apiGet<BudgetAccount[]>('/api/budget/accounts')
    48|export const getBudgetCategories = () => apiGet<BudgetCategory[]>('/api/budget/categories')
    49|export const getBudgetTags = () => apiGet<BudgetTag[]>('/api/budget/tags')
    50|export const previewBudgetTransaction = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transactions/preview', body)
    51|export const confirmBudgetTransaction = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transactions/confirm', body)
    52|export const updateBudgetTransaction = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transactions/${id}/update`, body)
    53|export const previewBudgetTransfer = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transfers/preview', body)
    54|export const confirmBudgetTransfer = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transfers/confirm', body)
    55|export const previewBudgetAccount = (body: unknown) => apiPost<BudgetPreview>('/api/budget/accounts/preview', body)
    56|export const confirmBudgetAccount = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/accounts/confirm', body)
    57|export const archiveBudgetAccount = (id: string) => apiPost<BudgetConfirm>(`/api/budget/accounts/${id}/archive`, {})
    58|export const previewBudgetCategory = (body: unknown) => apiPost<BudgetPreview>('/api/budget/categories/preview', body)
    59|export const confirmBudgetCategory = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/categories/confirm', body)
    60|export const updateBudgetCategory = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/update`, body)
    61|export const archiveBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/archive`, {})
    62|export const previewBudgetTag = (body: unknown) => apiPost<BudgetPreview>('/api/budget/tags/preview', body)
    63|export const confirmBudgetTag = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/tags/confirm', body)
    64|export const archiveBudgetTag = (id: string) => apiPost<BudgetConfirm>(`/api/budget/tags/${id}/archive`, {})
    65|export const getBudgetSeedCandidates = () => apiGet<BudgetSeedCandidate[]>('/api/budget/seed-candidates')
    66|export const generateBudgetSeedCandidates = (body: unknown) => apiPost<BudgetSeedSummary>('/api/budget/seed-candidates/generate', body)
    67|export const previewBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/seed-candidates/${id}/preview`, body)
    68|export const confirmBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/confirm`, body)
    69|export const ignoreBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/ignore`, { note })
    70|export const reopenBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/reopen`, { note })
    71|export const setBudgetSeedCandidateStatus = (id: string, status: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/status`, { status, note })
    72|export const convertBudgetUncleanRange = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/convert-unclean`, body)
    73|export const previewClearBudgetCategoriesTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; categories: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/categories/preview-clear-takeover', {})
    74|export const confirmClearBudgetCategoriesTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/categories/confirm-clear-takeover', {})
    75|export const previewBudgetPlanTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; budget_plans: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/budget-plans/preview-takeover', {})
    76|export const confirmBudgetPlanTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/budget-plans/confirm-takeover', {})
    77|export const getBudgetTransactionCandidates = (status = '', tab = '', filters: Record<string, string> = {}) => {
    78|  const params = new URLSearchParams()
    79|  if (status) params.set('status', status)
    80|  if (tab) params.set('tab', tab)
    81|  Object.entries(filters).forEach(([k, v]) => { if (v) params.set(k, v) })
    82|  const qs = params.toString()
    83|  return apiGet<BudgetTransactionCandidate[]>(`/api/budget/transaction-candidates${qs ? `?${qs}` : ''}`)
    84|}
    85|export const getBudgetTransactionCandidateSummary = (budget_year = '2026') => apiGet<BudgetReviewSummary>(`/api/budget/transaction-candidates/summary?budget_year=${budget_year}`)
    86|export const generateBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, number>>('/api/budget/transaction-candidates/generate', body)
    87|export const previewBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/transaction-candidates/${id}/preview`, body)
    88|export const confirmBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/confirm`, body)
    89|export const ignoreBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/ignore`, { note })
    90|export const reopenBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/reopen`, { note })
    91|export const updateBudgetTransactionCandidateCategory = (id: string, category_id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/category`, { category_id, note })
    92|export const bulkUpdateBudgetTransactionCandidateCategory = (candidate_ids: string[], category_id: string, note = '') => apiPost<{ updated_count: number; skipped_count: number }>('/api/budget/transaction-candidates/bulk-category', { candidate_ids, category_id, note })
    93|export const splitBudgetTransactionCandidate = (id: string, splits: unknown[]) => apiPost<BudgetConfirm & { split_count: number }>(`/api/budget/transaction-candidates/${id}/split`, { splits })
    94|export const markBudgetTransactionCandidateTransfer = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/transfer`, { note })
    95|export const previewSafeBudgetTransactionCandidates = () => apiPost<{ preview_id: string; summary: string; count: number; groups: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/transaction-candidates/batch-confirm-safe/preview', {})
    96|export const batchConfirmSafeBudgetTransactionCandidates = (account_id: string, candidate_ids: string[] = []) => apiPost<{ confirmed_count: number; skipped_count: number }>('/api/budget/transaction-candidates/batch-confirm-safe', { account_id, candidate_ids })
    97|export const deleteEmptyBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/delete-empty`, {})
    98|export const previewBudgetCategoryMerges = () => apiPost<{ preview_id: string; summary: string; suggestions: Array<{ target_category_id: string; target_name: string; source_category_ids: string[]; source_names: string[] }>; warnings: string[] }>('/api/budget/categories/merge/preview', {})
    99|export const confirmBudgetCategoryMerge = (body: unknown) => apiPost<BudgetConfirm & { moved_transactions: number; moved_budget_plans: number; moved_children: number }>('/api/budget/categories/merge/confirm', body)
   100|export const previewBatchBudgetTransactionCandidates = (candidate_ids: string[], account_id: string, budget_year = '2026') => apiPost<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>>; requires_explicit_confirm: boolean }>('/api/budget/transaction-candidates/batch/preview', { candidate_ids, account_id, budget_year })
   101|export const batchBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/batch', body)
   102|export const confirmBudgetTransactionCandidateSplit = (id: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/transaction-candidates/${id}/split/confirm`, body)
   103|export const createReviewCategoryForCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm & { category_id: string }>(`/api/budget/transaction-candidates/${id}/category/create`, body)
   104|export const getBudgetRules = () => apiGet<BudgetRule[]>('/api/budget/rules')
   105|export const createBudgetRule = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/rules', body)
   106|export const deactivateBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/deactivate`, {})
   107|export const testBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/test`, {})
   108|export const applyBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/apply`, {})
   109|


File unchanged since last read. The content from the earlier read_file result in this conversation is still current — refer to that instead of re-reading.


     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_imports import (
     6|    batch_confirm_safe_candidates,
     7|    cleanup_migros_article_candidates,
     8|    create_candidate_split,
     9|    list_transaction_candidates,
    10|    mark_candidate_transfer,
    11|    seed_bank_transfer_candidates_from_rows,
    12|    seed_credit_card_candidates_from_rows,
    13|    seed_migros_candidates_from_rows,
    14|    update_transaction_candidate_category,
    15|)
    16|from jarvis_finance.storage.database import connect_memory
    17|from jarvis_finance.storage.migrations import apply_migrations
    18|
    19|
    20|def db():
    21|    conn = connect_memory()
    22|    apply_migrations(conn)
    23|    return conn
    24|
    25|
    26|def add_categories(conn):
    27|    return {
    28|        "abos": confirm_create_category(conn, {"name": "Abos", "category_type": "expense"})["entity_id"],
    29|        "food": confirm_create_category(conn, {"name": "Essen & Haushalt", "category_type": "expense"})["entity_id"],
    30|        "income": confirm_create_category(conn, {"name": "Lohn", "category_type": "income"})["entity_id"],
    31|        "other": confirm_create_category(conn, {"name": "Sonstiges", "category_type": "expense"})["entity_id"],
    32|    }
    33|
    34|
    35|def test_migros_article_rows_are_aggregated_to_receipts_with_line_items_and_threshold_rules() -> None:
    36|    conn = db(); cats = add_categories(conn)
    37|    rows = [
    38|        {"Datum": "01.05.2026", "Zeit": "10:00", "Filiale": "M Test", "Kasse": "1", "Transaktion": "A", "Artikel": "Brot", "Umsatz": "12.00"},
    39|        {"Datum": "01.05.2026", "Zeit": "10:00", "Filiale": "M Test", "Kasse": "1", "Transaktion": "A", "Artikel": "Milch", "Umsatz": "9.00"},
    40|        {"Datum": "02.05.2026", "Zeit": "11:00", "Filiale": "M Test", "Kasse": "2", "Transaktion": "B", "Artikel": "Haushalt", "Umsatz": "40.00"},
    41|        {"Datum": "02.05.2026", "Zeit": "11:00", "Filiale": "M Test", "Kasse": "2", "Transaktion": "B", "Artikel": "Geschenk", "Umsatz": "20.00"},
    42|    ]
    43|    stats = seed_migros_candidates_from_rows(conn, rows, source_file_label="migros.csv", auto_category_id=cats["food"], threshold_chf="50.00")
    44|    assert stats["receipt_candidate_count"] == 2
    45|    assert stats["line_item_count"] == 4
    46|    assert stats["auto_assigned_count"] == 1
    47|    assert stats["review_required_count"] == 1
    48|    candidates = list_transaction_candidates(conn)
    49|    assert len(candidates) == 2
    50|    assert all(c["source_type"] == "migros_receipt" for c in candidates)
    51|    assert all("Brot" not in c["description"] and "Milch" not in c["description"] for c in candidates)
    52|    small = next(c for c in candidates if c["requires_review"] is False)
    53|    large = next(c for c in candidates if c["requires_review"] is True)
    54|    assert small["proposed_category_id"] == cats["food"]
    55|    assert large["review_reason"] == "migros_receipt_over_threshold"
    56|    assert conn.execute("SELECT COUNT(*) FROM budget_import_line_items").fetchone()[0] == 4
    57|
    58|
    59|def test_credit_card_rules_subscriptions_galaxus_migros_covered_and_duplicate_detection() -> None:
    60|    conn = db(); cats = add_categories(conn)
    61|    account = confirm_create_budget_account(conn, {"name": "Karte", "account_type": "credit_card", "currency": "CHF"})["entity_id"]
    62|    from jarvis_finance.services.budget_transactions import confirm_budget_transaction
    63|    confirm_budget_transaction(conn, {"account_id": account, "transaction_type": "expense", "transaction_date": "2026-05-04", "description": "NETFLIX", "amount_original": "19.90", "currency_original": "CHF", "category_id": cats["abos"]})
    64|    rows = [
    65|        {"Datum": "01.05.2026", "Beschreibung": "APPLE.COM/BILL", "Betrag": "9.90"},
    66|        {"Datum": "02.05.2026", "Beschreibung": "Galaxus Zürich", "Betrag": "120.00"},
    67|        {"Datum": "03.05.2026", "Beschreibung": "MIGROS MMM", "Betrag": "42.00"},
    68|        {"Datum": "04.05.2026", "Beschreibung": "NETFLIX", "Betrag": "19.90"},
    69|    ]
    70|    stats = seed_credit_card_candidates_from_rows(conn, rows, source_file_label="card.csv", subscription_category_id=cats["abos"], migros_covered=True)
    71|    assert stats["candidate_count"] == 4
    72|    assert stats["subscription_auto_count"] == 2
    73|    assert stats["galaxus_review_count"] == 1
    74|    assert stats["covered_by_migros_count"] == 1
    75|    assert stats["possible_duplicate_count"] == 1
    76|    by_desc = {c["description"]: c for c in list_transaction_candidates(conn)}
    77|    covered_by_desc = {c["description"]: c for c in list_transaction_candidates(conn, status="covered_by_migros")}
    78|    assert by_desc["APPLE.COM/BILL"]["proposed_category_id"] == cats["abos"]
    79|    assert by_desc["Galaxus Zürich"]["status"] == "needs_review"
    80|    assert covered_by_desc["MIGROS MMM"]["status"] == "covered_by_migros"
    81|    assert by_desc["NETFLIX"]["status"] == "duplicate"
    82|
    83|
    84|def test_bank_income_internal_transfer_and_unclear_payment_classification() -> None:
    85|    conn = db(); cats = add_categories(conn)
    86|    rows = [
    87|        {"Datum": "01.05.2026", "Beschreibung": "Lohn Arbeitgeber", "Betrag": "5000.00", "Konto": "AKB Melanie"},
    88|        {"Datum": "02.05.2026", "Beschreibung": "Übertrag an Raiffeisen Marcel", "Betrag": "-1000.00", "Konto": "AKB Melanie"},
    89|        {"Datum": "03.05.2026", "Beschreibung": "Gutschrift von AKB Melanie", "Betrag": "1000.00", "Konto": "Raiffeisen Marcel"},
    90|        {"Datum": "04.05.2026", "Beschreibung": "Unklare Zahlung", "Betrag": "-77.00", "Konto": "AKB Melanie"},
    91|    ]
    92|    stats = seed_bank_transfer_candidates_from_rows(conn, rows, source_file_label="bank.csv", income_category_id=cats["income"])
    93|    assert stats["candidate_count"] == 4
    94|    assert stats["income_candidate_count"] == 1
    95|    assert stats["transfer_candidate_count"] == 2
    96|    assert stats["review_required_count"] == 1
    97|    candidates = list_transaction_candidates(conn)
    98|    assert len([c for c in candidates if c["classification"] == "income_candidate"]) == 1
    99|    assert len([c for c in candidates if c["classification"] == "transfer_candidate"]) == 2
   100|    assert next(c for c in candidates if c["description"] == "Unklare Zahlung")["status"] == "needs_review"
   101|
   102|
   103|def test_review_actions_category_split_ignore_transfer_and_batch_confirm_safe_candidates() -> None:
   104|    conn = db(); cats = add_categories(conn)
   105|    account = confirm_create_budget_account(conn, {"name": "Budget", "account_type": "cash", "currency": "CHF"})["entity_id"]
   106|    seed_credit_card_candidates_from_rows(conn, [{"Datum": "01.05.2026", "Beschreibung": "APPLE", "Betrag": "9.90"}], source_file_label="card.csv", subscription_category_id=cats["abos"])
   107|    seed_migros_candidates_from_rows(conn, [{"Datum": "02.05.2026", "Zeit": "10:00", "Filiale": "M", "Kasse": "1", "Transaktion": "A", "Artikel": "X", "Umsatz": "12.00"}], source_file_label="migros.csv", auto_category_id=cats["food"])
   108|    cid = list_transaction_candidates(conn)[0]["transaction_candidate_id"]
   109|    assert update_transaction_candidate_category(conn, cid, cats["other"], note="review") ["status"] == "updated"
   110|    assert create_candidate_split(conn, cid, [{"category_id": cats["food"], "amount_original": "5.00"}, {"category_id": cats["other"], "amount_original": "4.90"}])["split_count"] == 2
   111|    assert mark_candidate_transfer(conn, cid, note="own transfer")["status"] == "transfer_candidate"
   112|    safe = batch_confirm_safe_candidates(conn, account_id=account, candidate_ids=[c["transaction_candidate_id"] for c in list_transaction_candidates(conn) if c["classification"] in {"subscription_auto", "migros_auto_under_threshold"}])
   113|    assert safe["confirmed_count"] >= 1
   114|    assert conn.execute("SELECT COUNT(*) FROM audit_log WHERE entity_type IN ('budget_transaction_candidate','budget_transaction_candidate_split')").fetchone()[0] >= 3
   115|
   116|
   117|def test_cleanup_migros_article_candidates_marks_superseded_and_audits() -> None:
   118|    conn = db(); add_categories(conn)
   119|    from jarvis_finance.services.budget_imports import seed_transaction_candidates_from_rows
   120|    seed_transaction_candidates_from_rows(conn, [{"Datum": "01.05.2026", "Filiale": "M", "Artikel": "Brot", "Umsatz": "1.00"}], source_file_label="migros_old.csv")
   121|    stats = cleanup_migros_article_candidates(conn, dry_run=False)
   122|    assert stats["article_candidate_count"] == 1
   123|    assert stats["superseded_count"] == 1
   124|    row = list_transaction_candidates(conn, status="superseded")[0]
   125|    assert row["status"] == "superseded"
   126|    assert conn.execute("SELECT COUNT(*) FROM audit_log WHERE action='cleanup_migros_article_candidates'").fetchone()[0] == 1
   127|