     1|from __future__ import annotations
     2|
     3|import hashlib
     4|import shutil
     5|from dataclasses import dataclass
     6|from datetime import datetime, timezone
     7|from pathlib import Path
     8|
     9|from jarvis_finance.config.paths import is_relative_to
    10|
    11|
    12|@dataclass(frozen=True)
    13|class BackupResult:
    14|    backup_file: Path
    15|    checksum_file: Path
    16|    sha256: str
    17|    size_bytes: int
    18|
    19|
    20|@dataclass(frozen=True)
    21|class VerifyBackupResult:
    22|    backup_file: Path
    23|    checksum_file: Path
    24|    sha256: str
    25|    checksum_ok: bool
    26|    exists: bool
    27|
    28|
    29|@dataclass(frozen=True)
    30|class RestoreResult:
    31|    restored_to: Path
    32|    source_file: Path
    33|    verified: bool
    34|
    35|
    36|def sha256_file(path: Path) -> str:
    37|    digest = hashlib.sha256()
    38|    with path.open("rb") as handle:
    39|        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
    40|            digest.update(chunk)
    41|    return digest.hexdigest()
    42|
    43|
    44|def write_checksum_file(backup_file: Path, sha256: str) -> Path:
    45|    checksum_file = backup_file.with_suffix(backup_file.suffix + ".sha256")
    46|    checksum_file.write_text(f"{sha256}  {backup_file.name}\n", encoding="utf-8")
    47|    return checksum_file
    48|
    49|
    50|def read_expected_checksum(checksum_file: Path) -> str | None:
    51|    if not checksum_file.exists():
    52|        return None
    53|    text = checksum_file.read_text(encoding="utf-8", errors="ignore").strip()
    54|    if not text:
    55|        return None
    56|    return text.split()[0]
    57|
    58|
    59|def ensure_outside_repo(path: Path, repo_root: Path) -> None:
    60|    if is_relative_to(path.resolve(), repo_root.resolve()):
    61|        raise ValueError(f"Backup/restore path must be outside the Git repository: {path}")
    62|
    63|
    64|def backup_runtime_db(*, db_path: Path, backups_dir: Path, repo_root: Path, timestamp: str | None = None) -> BackupResult:
    65|    db_path = db_path.expanduser().resolve()
    66|    backups_dir = backups_dir.expanduser().resolve()
    67|    ensure_outside_repo(backups_dir, repo_root)
    68|    ensure_outside_repo(db_path, repo_root)
    69|    if not db_path.exists():
    70|        raise FileNotFoundError(f"Runtime DB not found: {db_path}")
    71|    backups_dir.mkdir(parents=True, exist_ok=True)
    72|    stamp = timestamp or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    73|    backup_file = backups_dir / f"finance-runtime-db-{stamp}.sqlite3"
    74|    shutil.copy2(db_path, backup_file)
    75|    checksum = sha256_file(backup_file)
    76|    checksum_file = write_checksum_file(backup_file, checksum)
    77|    return BackupResult(
    78|        backup_file=backup_file,
    79|        checksum_file=checksum_file,
    80|        sha256=checksum,
    81|        size_bytes=backup_file.stat().st_size,
    82|    )
    83|
    84|
    85|def verify_backup(*, backup_file: Path, repo_root: Path | None = None) -> VerifyBackupResult:
    86|    backup_file = backup_file.expanduser().resolve()
    87|    if repo_root is not None:
    88|        ensure_outside_repo(backup_file, repo_root)
    89|    exists = backup_file.exists() and backup_file.is_file()
    90|    if not exists:
    91|        return VerifyBackupResult(
    92|            backup_file=backup_file,
    93|            checksum_file=backup_file.with_suffix(backup_file.suffix + ".sha256"),
    94|            sha256="",
    95|            checksum_ok=False,
    96|            exists=False,
    97|        )
    98|    actual = sha256_file(backup_file)
    99|    checksum_file = backup_file.with_suffix(backup_file.suffix + ".sha256")
   100|    expected = read_expected_checksum(checksum_file)
   101|    return VerifyBackupResult(
   102|        backup_file=backup_file,
   103|        checksum_file=checksum_file,
   104|        sha256=actual,
   105|        checksum_ok=bool(expected and expected == actual),
   106|        exists=True,
   107|    )
   108|
   109|
   110|def restore_runtime_db(*, backup_file: Path, db_path: Path, repo_root: Path, yes: bool = False) -> RestoreResult:
   111|    if not yes:
   112|        raise PermissionError("Restore requires explicit confirmation via --yes")
   113|    backup_file = backup_file.expanduser().resolve()
   114|    db_path = db_path.expanduser().resolve()
   115|    ensure_outside_repo(backup_file, repo_root)
   116|    ensure_outside_repo(db_path, repo_root)
   117|    verification = verify_backup(backup_file=backup_file, repo_root=repo_root)
   118|    if not verification.exists:
   119|        raise FileNotFoundError(f"Backup file not found: {backup_file}")
   120|    if not verification.checksum_ok:
   121|        raise ValueError(f"Backup checksum verification failed: {backup_file}")
   122|    db_path.parent.mkdir(parents=True, exist_ok=True)
   123|    shutil.copy2(backup_file, db_path)
   124|    return RestoreResult(restored_to=db_path, source_file=backup_file, verified=True)
   125|


from hermes_tools import terminal
wd='/home/agent/.hermes/repos/FinanceManager'
cmd=r'''
python - <<'PY'
from pathlib import Path
from datetime import datetime, timezone
from jarvis_finance.runtime.backup import backup_runtime_db, verify_backup
repo=Path('/home/agent/.hermes/repos/FinanceManager')
db=Path('/home/agent/jarvis_runtime/finance-system/data/finance.sqlite3')
backups=Path('/home/agent/jarvis_runtime/finance-system/backups')
stamp='budget-import-review-uat-v2-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
r=backup_runtime_db(db_path=db, backups_dir=backups, repo_root=repo, timestamp=stamp)
v=verify_backup(backup_file=r.backup_file, repo_root=repo)
print({'backup_file': str(r.backup_file), 'checksum_file': str(r.checksum_file), 'exists': v.exists, 'checksum_ok': v.checksum_ok, 'outside_repo': not str(r.backup_file).startswith(str(repo)), 'size_bytes': r.size_bytes})
PY
'''
print(terminal(cmd, workdir=wd, timeout=120)['output'])


Output: {'backup_file': '/home/agent/jarvis_runtime/finance-system/backups/finance-runtime-db-budget-import-review-uat-v2-20260518T092019Z.sqlite3', 'checksum_file': '/home/agent/jarvis_runtime/finance-system/backups/finance-runtime-db-budget-import-review-uat-v2-20260518T092019Z.sqlite3.sha256', 'exists': True, 'checksum_ok': True, 'outside_repo': True, 'size_bytes': 16814080}


Backup ist erstellt und verifiziert. Jetzt aggregiere ich Kandidaten nach Quelle/Status/Klassifikation — ohne Beträge und ohne Einzelrows im Chat.

Explanation:
**Aggregating candidate data**

I need to aggregate candidate data and find their statuses and classifications. I’ll use SQL counts, focusing on counts rather than values. It’s also important to check if the proposed_category_id is not null and that those requiring review are distinguished. I should make sure to distinguish the sources and can output counts after that. Additionally, I need to select the UAT set and inspect the API and services to confirm it. I'll read through budget_transactions and budget_imports too.

from hermes_tools import terminal
wd='/home/agent/.hermes/repos/FinanceManager'
cmd=r'''
python - <<'PY'
import sqlite3, json
p='/home/agent/jarvis_runtime/finance-system/data/finance.sqlite3'
con=sqlite3.connect(p); con.row_factory=sqlite3.Row; cur=con.cursor()
def one(q, params=()): return cur.execute(q, params).fetchone()[0]
def rows(q, params=()): return [dict(r) for r in cur.execute(q, params)]
summary={}
summary['total_candidates']=one('select count(*) from budget_transaction_candidates')
summary['by_source']=rows("select source_type, count(*) count from budget_transaction_candidates group by source_type order by source_type")
summary['by_status']=rows("select coalesce(status,'') status, count(*) count from budget_transaction_candidates group by status order by count desc")
summary['by_classification']=rows("select coalesce(classification,'') classification, count(*) count from budget_transaction_candidates group by classification order by count desc")
summary['auto_categorized']=one("select count(*) from budget_transaction_candidates where proposed_category_id is not null or status='auto_categorized'")
summary['review_needed']=one("select count(*) from budget_transaction_candidates where requires_review=1 or status in ('needs_review','duplicate_candidate','transfer_candidate')")
summary['transfers']=one("select count(*) from budget_transaction_candidates where classification='transfer' or status='transfer_candidate'")
summary['investment_transfers']=one("select count(*) from budget_transaction_candidates where classification='investment_transfer'")
summary['credit_card_payment']=one("select count(*) from budget_transaction_candidates where classification='credit_card_payment'")
summary['covered_by_migros']=one("select count(*) from budget_transaction_candidates where classification='covered_by_migros' or covered_by_source='migros_receipts'")
summary['duplicate_candidates']=one("select count(*) from budget_transaction_candidates where duplicate_of_transaction_id is not null or status like '%duplicate%'")
summary['income_candidates']=one("select count(*) from budget_transaction_candidates where classification='income'")
summary['unclear_candidates']=one("select count(*) from budget_transaction_candidates where (classification is null or classification='' or classification='expense') and (requires_review=1 or status='needs_review')")
summary['confirmed_by_source']=rows("select source_type, count(*) count from budget_transaction_candidates where status='confirmed' group by source_type order by source_type")
summary['candidate_minmax_by_source']=rows("select source_type, min(transaction_date) min_date, max(transaction_date) max_date, count(*) count from budget_transaction_candidates group by source_type order by source_type")
print(json.dumps(summary, ensure_ascii=False, indent=2))
con.close()
PY
'''
print(terminal(cmd, workdir=wd, timeout=120)['output'])


Output:
{
  "total_candidates": 13274,
  "by_source": [
    {
      "source_type": "akb_bank",
      "count": 332
    },
    {
      "source_type": "credit_card_csv",
      "count": 1376
    },
    {
      "source_type": "csv_seed",
      "count": 9640
    },
    {
      "source_type": "excel_budget_tool",
      "count": 175
    },
    {
      "source_type": "migros_receipt",
      "count": 279
    },
    {
      "source_type": "migros_receipts",
      "count": 49
    },
    {
      "source_type": "raiffeisen_bank",
      "count": 47
    },
    {
      "source_type": "visa_credit_card",
      "count": 1376
    }
  ],
  "by_status": [
    {
      "status": "superseded",
      "count": 8328
    },
    {
      "status": "needs_review",
      "count": 2583
    },
    {
      "status": "reference_2025",
      "count": 1351
    },
    {
      "status": "covered_by_migros",
      "count": 410
    },
    {
      "status": "pending",
      "count": 297
    },
    {
      "status": "auto_categorized",
      "count": 125
    },
    {
      "status": "confirmed",
      "count": 68
    },
    {
      "status": "transfer_candidate",
      "count": 61
    },
    {
      "status": "duplicate_candidate",
      "count": 49
    },
    {
      "status": "ignored",
      "count": 2
    }
  ],
  "by_classification": [
    {
      "classification": "",
      "count": 9813
    },
    {
      "classification": "card_uncategorized",
      "count": 1694
    },
    {
      "classification": "covered_by_migros",
      "count": 540
    },
    {
      "classification": "bank_review",
      "count": 318
    },
    {
      "classification": "galaxus_review",
      "count": 238
    },
    {
      "classification": "migros_review_over_threshold",
      "count": 182
    },
    {
      "classification": "subscription_auto",
      "count": 179
    },
    {
      "classification": "migros_receipt_food_household",
      "count": 96
    },
    {
      "classification": "transfer_candidate",
      "count": 61
    },
    {
      "classification": "migros_auto_under_threshold",
      "count": 50
    },
    {
      "classification": "food_household",
      "count": 33
    },
    {
      "classification": "unclear",
      "count": 29
    },
    {
      "classification": "auto_transport",
      "count": 13
    },
    {
      "classification": "home_garden",
      "count": 13
    },
    {
      "classification": "health_medical",
      "count": 8
    },
    {
      "classification": "shopping",
      "count": 3
    },
    {
      "classification": "legacy_csv_seed",
      "count": 2
    },
    {
      "classification": "admin_other",
      "count": 1
    },
    {
      "classification": "pets",
      "count": 1
    }
  ],
  "auto_categorized": 9208,
  "review_needed": 2695,
  "transfers": 61,
  "investment_transfers": 0,
  "credit_card_payment": 0,
  "covered_by_migros": 540,
  "duplicate_candidates": 49,
  "income_candidates": 0,
  "unclear_candidates": 592,
  "confirmed_by_source": [
    {
      "source_type": "migros_receipt",
      "count": 68
    }
  ],
  "candidate_minmax_by_source": [
    {
      "source_type": "akb_bank",
      "min_date": "2025-05-26",
      "max_date": "2026-05-18",
      "count": 332
    },
    {
      "source_type": "credit_card_csv",
      "min_date": "2024-04-11",
      "max_date": "2026-05-12",
      "count": 1376
    },
    {
      "source_type": "csv_seed",
      "min_date": "2024-01-03",
      "max_date": "2026-05-13",
      "count": 9640
    },
    {
      "source_type": "excel_budget_tool",
      "min_date": "10413.2824",
      "max_date": "4166.66666",
      "count": 175
    },
    {
      "source_type": "migros_receipt",
      "min_date": "2024-01-03",
      "max_date": "2026-05-13",
      "count": 279
    },
    {
      "source_type": "migros_receipts",
      "min_date": "2026-01-02",
      "max_date": "2026-05-13",
      "count": 49
    },
    {
      "source_type": "raiffeisen_bank",
      "min_date": "2026-01-05",
      "max_date": "2026-05-15",
      "count": 47
    },
    {
      "source_type": "visa_credit_card",
      "min_date": "2024-04-11",
      "max_date": "2026-05-12",
      "count": 1376
    }
  ]
}


Kandidatenübersicht liegt aggregiert vor. Auffällig: es gibt Legacy-/superseded-Altquellen neben den v2-Quellen; UAT beschränke ich auf `visa_credit_card`, `migros_receipts`, `raiffeisen_bank`, `akb_bank`. Ich lese jetzt Confirm-/Preview-Endpunkte und wähle intern maximal die sicheren Stichproben aus.


     1|from __future__ import annotations
     2|
     3|from fastapi import APIRouter, Depends
     4|from sqlite3 import Connection
     5|
     6|from jarvis_finance.api.dependencies import get_db
     7|from jarvis_finance.services.budget_accounts import archive_budget_account, confirm_create_budget_account, create_budget_account_preview, list_budget_accounts
     8|from jarvis_finance.services.budget_categories import (
     9|    archive_category,
    10|    archive_tag,
    11|    confirm_category_merge,
    12|    confirm_create_category,
    13|    confirm_create_tag,
    14|    create_category_preview,
    15|    create_tag_preview,
    16|    delete_empty_category,
    17|    list_category_tree_grouped,
    18|    list_categories,
    19|    list_tags,
    20|    preview_category_merges,
    21|    update_category,
    22|)
    23|from jarvis_finance.services.budget_csv_imports import import_budget_csv_text, import_profile_specs
    24|from jarvis_finance.services.budget_imports import (
    25|    archive_reference_year_candidates,
    26|    apply_budget_rules_to_open_candidates,
    27|    apply_merchant_to_open_candidates,
    28|    batch_confirm_safe_candidates,
    29|    batch_mark_candidates,
    30|    confirm_migros_receipts_2026,
    31|    bulk_update_transaction_candidate_category,
    32|    confirm_candidate_split,
    33|    confirm_transaction_candidate,
    34|    create_budget_rule,
    35|    create_merchant,
    36|    create_merchant_alias,
    37|    create_candidate_split,
    38|    create_review_category_for_candidate,
    39|    deactivate_budget_rule,
    40|    detect_duplicate_candidates,
    41|    ignore_transaction_candidate,
    42|    list_budget_rules,
    43|    list_merchants,
    44|    list_transaction_candidates,
    45|    merge_merchants,
    46|    mark_candidate_transfer,
    47|    mark_candidate_type,
    48|    preview_migros_receipts_2026,
    49|    preview_batch_confirm_candidates,
    50|    preview_safe_auto_candidates,
    51|    preview_transaction_candidate,
    52|    prioritize_budget_rule,
    53|    reopen_transaction_candidate,
    54|    review_candidate_summary,
    55|    seed_bank_transfer_candidates_from_rows,
    56|    seed_credit_card_candidates_from_rows,
    57|    seed_migros_candidates_from_rows,
    58|    seed_transaction_candidates_from_csv_text,
    59|    seed_transaction_candidates_from_rows,
    60|    test_budget_rule,
    61|    update_budget_rule,
    62|    update_merchant_category,
    63|    update_transaction_candidate_category,
    64|)
    65|from jarvis_finance.services.budget_analytics import get_category_analysis, get_monthly_comparison
    66|from jarvis_finance.services.budget_analytics_v2 import get_budget_dashboard_cockpit, get_budget_vs_actual_analysis
    67|from jarvis_finance.services.budget_overview import get_budget_charts_2026, get_budget_overview, get_budget_status_by_category, get_category_month_detail, get_category_summary, get_income_status, get_monthly_summary
    68|from jarvis_finance.services.budget_plans import archive_budget_plan_item, confirm_budget_plan_item, create_budget_plan_item_preview, list_budget_plan_items, list_income_plan_items, list_income_seed_candidates
    69|from jarvis_finance.services.budget_seed_review import (
    70|    confirm_budget_plan_takeover,
    71|    confirm_clear_category_takeover,
    72|    confirm_seed_candidate,
    73|    convert_unclean_range,
    74|    ignore_seed_candidate,
    75|    list_seed_candidates,
    76|    preview_budget_plan_takeover,
    77|    preview_clear_category_takeover,
    78|    preview_seed_candidate,
    79|    reopen_seed_candidate,
    80|    seed_budget_candidates_from_rows,
    81|    set_seed_candidate_status,
    82|)
    83|from jarvis_finance.services.budget_transactions import (
    84|    confirm_budget_transaction,
    85|    confirm_reverse_budget_transaction,
    86|    confirm_transfer,
    87|    create_budget_transaction_preview,
    88|    create_transfer_preview,
    89|    list_budget_transactions,
    90|    reverse_budget_transaction_preview,
    91|    update_budget_transaction,
    92|)
    93|
    94|router = APIRouter(prefix="/budget", tags=["budget"])
    95|
    96|
    97|@router.get("/dashboard/cockpit")
    98|def dashboard_cockpit(month: str = "2026-05", conn: Connection = Depends(get_db)):
    99|    return get_budget_dashboard_cockpit(conn, month=month)
   100|
   101|
   102|@router.get("/analysis/budget-vs-actual")
   103|def budget_vs_actual_analysis(year: str = "2026", conn: Connection = Depends(get_db)):
   104|    return get_budget_vs_actual_analysis(conn, year=year)
   105|
   106|
   107|@router.get("/overview")
   108|def overview(month: str | None = None, conn: Connection = Depends(get_db)):
   109|    return get_budget_overview(conn, month)
   110|
   111|
   112|@router.get("/status/categories")
   113|def category_budget_status(year: str = "2026", conn: Connection = Depends(get_db)):
   114|    return get_budget_status_by_category(conn, year=year)
   115|
   116|
   117|@router.get("/status/categories/{category_id}/months/{month}")
   118|def category_month_detail(category_id: str, month: str, conn: Connection = Depends(get_db)):
   119|    return get_category_month_detail(conn, category_id=category_id, month=month)
   120|
   121|
   122|@router.get("/charts")
   123|def budget_charts(year: str = "2026", conn: Connection = Depends(get_db)):
   124|    return get_budget_charts_2026(conn, year=year)
   125|
   126|
   127|@router.get("/analysis/categories")
   128|def category_analysis(year: str = "2026", month: str | None = None, tx_type: str = "expense", conn: Connection = Depends(get_db)):
   129|    return get_category_analysis(conn, year=year, month=month, tx_type=tx_type)
   130|
   131|
   132|@router.get("/analysis/monthly-comparison")
   133|def monthly_comparison(year: str = "2026", conn: Connection = Depends(get_db)):
   134|    return get_monthly_comparison(conn, year=year)
   135|
   136|
   137|@router.get("/income/status")
   138|def income_budget_status(year: str = "2026", conn: Connection = Depends(get_db)):
   139|    return get_income_status(conn, year=year)
   140|
   141|
   142|@router.get("/income/plans")
   143|def income_plans(year: str = "2026", include_archived: bool = False, conn: Connection = Depends(get_db)):
   144|    return list_income_plan_items(conn, year=year, include_archived=include_archived)
   145|
   146|
   147|@router.get("/income/seed-candidates")
   148|def income_seed_candidates(year: str = "2026", conn: Connection = Depends(get_db)):
   149|    return list_income_seed_candidates(conn, year=year)
   150|
   151|
   152|@router.post("/income/plans/preview")
   153|def income_plans_preview(payload: dict, conn: Connection = Depends(get_db)):
   154|    return create_budget_plan_item_preview(conn, payload)
   155|
   156|
   157|@router.post("/income/plans/confirm")
   158|def income_plans_confirm(payload: dict, conn: Connection = Depends(get_db)):
   159|    return confirm_budget_plan_item(conn, payload)
   160|
   161|
   162|@router.post("/income/plans/{plan_item_id}/archive")
   163|def income_plans_archive(plan_item_id: str, conn: Connection = Depends(get_db)):
   164|    return archive_budget_plan_item(conn, plan_item_id)
   165|
   166|
   167|@router.get("/monthly-summary")
   168|def monthly_summary(month: str | None = None, conn: Connection = Depends(get_db)):
   169|    return get_monthly_summary(conn, month)
   170|
   171|
   172|@router.get("/categories/summary")
   173|def categories_summary(month: str | None = None, conn: Connection = Depends(get_db)):
   174|    return get_category_summary(conn, month)
   175|
   176|
   177|@router.get("/accounts")
   178|def accounts(include_archived: bool = False, conn: Connection = Depends(get_db)):
   179|    return list_budget_accounts(conn, include_archived=include_archived)
   180|
   181|
   182|@router.post("/accounts/preview")
   183|def accounts_preview(payload: dict, conn: Connection = Depends(get_db)):
   184|    return create_budget_account_preview(conn, payload)
   185|
   186|
   187|@router.post("/accounts/confirm")
   188|def accounts_confirm(payload: dict, conn: Connection = Depends(get_db)):
   189|    return confirm_create_budget_account(conn, payload)
   190|
   191|
   192|@router.post("/accounts/{account_id}/archive")
   193|def accounts_archive(account_id: str, conn: Connection = Depends(get_db)):
   194|    return archive_budget_account(conn, account_id)
   195|
   196|
   197|@router.get("/categories")
   198|def categories(include_archived: bool = False, grouped: bool = False, conn: Connection = Depends(get_db)):
   199|    if grouped:
   200|        return list_category_tree_grouped(conn, include_archived=include_archived)
   201|    return list_categories(conn, include_archived=include_archived)
   202|
   203|
   204|@router.get("/categories/tree")
   205|def category_tree(include_archived: bool = False, conn: Connection = Depends(get_db)):
   206|    return list_category_tree_grouped(conn, include_archived=include_archived)
   207|
   208|
   209|@router.get("/tags")
   210|def tags(include_archived: bool = False, conn: Connection = Depends(get_db)):
   211|    return list_tags(conn, include_archived=include_archived)
   212|
   213|
   214|@router.post("/tags/preview")
   215|def tags_preview(payload: dict, conn: Connection = Depends(get_db)):
   216|    return create_tag_preview(conn, payload)
   217|
   218|
   219|@router.post("/tags/confirm")
   220|def tags_confirm(payload: dict, conn: Connection = Depends(get_db)):
   221|    return confirm_create_tag(conn, payload)
   222|
   223|
   224|@router.post("/tags/{tag_id}/archive")
   225|def tags_archive(tag_id: str, conn: Connection = Depends(get_db)):
   226|    return archive_tag(conn, tag_id)
   227|
   228|
   229|@router.get("/plans")
   230|def plans(month: str | None = None, include_archived: bool = True, conn: Connection = Depends(get_db)):
   231|    return list_budget_plan_items(conn, month=month, include_archived=include_archived)
   232|
   233|
   234|@router.post("/plans/preview")
   235|def plans_preview(payload: dict, conn: Connection = Depends(get_db)):
   236|    return create_budget_plan_item_preview(conn, payload)
   237|
   238|
   239|@router.post("/plans/confirm")
   240|def plans_confirm(payload: dict, conn: Connection = Depends(get_db)):
   241|    return confirm_budget_plan_item(conn, payload)
   242|
   243|
   244|@router.get("/seed-candidates")
   245|def seed_candidates(status: str | None = None, candidate_type: str | None = None, conn: Connection = Depends(get_db)):
   246|    return list_seed_candidates(conn, status=status, candidate_type=candidate_type)
   247|
   248|
   249|@router.get("/import-profiles")
   250|def budget_import_profiles():
   251|    return import_profile_specs()
   252|
   253|
   254|@router.post("/import-profiles/{profile_name}/dry-run")
   255|def budget_import_profile_dry_run(profile_name: str, payload: dict, conn: Connection = Depends(get_db)):
   256|    return import_budget_csv_text(conn, profile_name, str(payload.get("csv_text") or ""), source_file_label=payload.get("source_file_label") or f"{profile_name}.csv", dry_run=True, default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   257|
   258|
   259|@router.post("/import-profiles/{profile_name}/candidates")
   260|def budget_import_profile_candidates(profile_name: str, payload: dict, conn: Connection = Depends(get_db)):
   261|    return import_budget_csv_text(conn, profile_name, str(payload.get("csv_text") or ""), source_file_label=payload.get("source_file_label") or f"{profile_name}.csv", dry_run=False, default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   262|
   263|
   264|@router.get("/transaction-candidates")
   265|def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
   266|    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)
   267|
   268|
   269|@router.get("/transaction-candidates/summary")
   270|def transaction_candidates_summary(budget_year: str = "2026", conn: Connection = Depends(get_db)):
   271|    return review_candidate_summary(conn, budget_year=budget_year)
   272|
   273|
   274|@router.post("/transaction-candidates/generate")
   275|def transaction_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   276|    source_type = payload.get("source_type") or "csv_seed"
   277|    rows = payload.get("rows") or []
   278|    if source_type == "credit_card_csv":
   279|        return seed_credit_card_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Credit card CSV", subscription_category_id=payload.get("subscription_category_id"), migros_covered=payload.get("migros_covered", True))
   280|    if source_type == "migros_csv":
   281|        return seed_migros_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Migros CSV", auto_category_id=payload.get("auto_category_id"), threshold_chf=str(payload.get("threshold_chf") or "50.00"))
   282|    if source_type == "bank_csv":
   283|        return seed_bank_transfer_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Bank CSV", income_category_id=payload.get("income_category_id"))
   284|    if payload.get("csv_text"):
   285|        return seed_transaction_candidates_from_csv_text(conn, payload["csv_text"], source_file_label=payload.get("source_file_label") or "CSV Import", source_type=source_type)
   286|    return seed_transaction_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Budget Import", source_type=source_type)
   287|
   288|
   289|@router.post("/transaction-candidates/migros-2026/preview")
   290|def transaction_candidates_migros_2026_preview(payload: dict | None = None, conn: Connection = Depends(get_db)):
   291|    return preview_migros_receipts_2026(conn, year=str((payload or {}).get("year") or "2026"))
   292|
   293|
   294|@router.post("/transaction-candidates/migros-2026/confirm")
   295|def transaction_candidates_migros_2026_confirm(payload: dict, conn: Connection = Depends(get_db)):
   296|    return confirm_migros_receipts_2026(conn, account_id=str(payload.get("account_id") or ""), year=str(payload.get("year") or "2026"))
   297|
   298|
   299|@router.post("/transaction-candidates/{candidate_id}/preview")
   300|def transaction_candidates_preview(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   301|    return preview_transaction_candidate(conn, candidate_id, payload or {})
   302|
   303|
   304|@router.post("/transaction-candidates/{candidate_id}/confirm")
   305|def transaction_candidates_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   306|    return confirm_transaction_candidate(conn, candidate_id, payload)
   307|
   308|
   309|@router.post("/transaction-candidates/{candidate_id}/ignore")
   310|def transaction_candidates_ignore(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   311|    return ignore_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   312|
   313|
   314|@router.post("/transaction-candidates/{candidate_id}/reopen")
   315|def transaction_candidates_reopen(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   316|    return reopen_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   317|
   318|
   319|@router.post("/transaction-candidates/{candidate_id}/category")
   320|def transaction_candidates_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   321|    return update_transaction_candidate_category(conn, candidate_id, payload.get("category_id"), payload.get("note"))
   322|
   323|
   324|@router.post("/transaction-candidates/bulk-category")
   325|def transaction_candidates_bulk_category(payload: dict, conn: Connection = Depends(get_db)):
   326|    category_id = str(payload.get("category_id") or "")
   327|    return bulk_update_transaction_candidate_category(conn, candidate_ids=payload.get("candidate_ids") or [], category_id=category_id, note=payload.get("note"))
   328|
   329|
   330|@router.post("/transaction-candidates/{candidate_id}/split")
   331|def transaction_candidates_split(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   332|    return create_candidate_split(conn, candidate_id, payload.get("splits") or [])
   333|
   334|
   335|@router.post("/transaction-candidates/{candidate_id}/split/confirm")
   336|def transaction_candidates_split_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   337|    return confirm_candidate_split(conn, candidate_id, payload)
   338|
   339|
   340|@router.post("/transaction-candidates/{candidate_id}/category/create")
   341|def transaction_candidates_create_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   342|    return create_review_category_for_candidate(conn, candidate_id, payload)
   343|
   344|
   345|@router.post("/transaction-candidates/{candidate_id}/transfer")
   346|def transaction_candidates_transfer(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   347|    return mark_candidate_transfer(conn, candidate_id, (payload or {}).get("note"))
   348|
   349|
   350|@router.post("/transaction-candidates/{candidate_id}/type")
   351|def transaction_candidates_type(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   352|    return mark_candidate_type(conn, candidate_id, str(payload.get("candidate_type") or ""), (payload or {}).get("note"))
   353|
   354|
   355|@router.post("/transaction-candidates/batch-confirm-safe/preview")
   356|def transaction_candidates_batch_confirm_safe_preview(conn: Connection = Depends(get_db)):
   357|    return preview_safe_auto_candidates(conn)
   358|
   359|
   360|@router.post("/transaction-candidates/batch/preview")
   361|def transaction_candidates_batch_preview(payload: dict, conn: Connection = Depends(get_db)):
   362|    return preview_batch_confirm_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], account_id=str(payload.get("account_id") or ""), budget_year=str(payload.get("budget_year") or "2026"))
   363|
   364|
   365|@router.post("/transaction-candidates/batch")
   366|def transaction_candidates_batch(payload: dict, conn: Connection = Depends(get_db)):
   367|    return batch_mark_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], action=str(payload.get("action") or ""), account_id=payload.get("account_id"), category_id=payload.get("category_id"), preview_id=payload.get("preview_id"), note=payload.get("note"), budget_year=str(payload.get("budget_year") or "2026"))
   368|
   369|
   370|@router.post("/transaction-candidates/archive-reference-year")
   371|def transaction_candidates_archive_reference(payload: dict | None = None, conn: Connection = Depends(get_db)):
   372|    payload = payload or {}
   373|    return archive_reference_year_candidates(conn, active_year=str(payload.get("active_year") or "2026"), reference_year=str(payload.get("reference_year") or "2025"))
   374|
   375|
   376|@router.post("/transaction-candidates/batch-confirm-safe")
   377|def transaction_candidates_batch_confirm_safe(payload: dict, conn: Connection = Depends(get_db)):
   378|    account_id = str(payload.get("account_id") or "")
   379|    return batch_confirm_safe_candidates(conn, account_id=account_id, candidate_ids=payload.get("candidate_ids") or None)
   380|
   381|
   382|@router.post("/transaction-candidates/detect-duplicates")
   383|def transaction_candidates_detect_duplicates(conn: Connection = Depends(get_db)):
   384|    return detect_duplicate_candidates(conn)
   385|
   386|
   387|@router.get("/merchants")
   388|def merchants(include_inactive: bool = False, conn: Connection = Depends(get_db)):
   389|    return list_merchants(conn, include_inactive=include_inactive)
   390|
   391|
   392|@router.post("/merchants")
   393|def merchants_create(payload: dict, conn: Connection = Depends(get_db)):
   394|    return create_merchant(conn, payload)
   395|
   396|
   397|@router.post("/merchants/{merchant_id}/aliases")
   398|def merchants_create_alias(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   399|    return create_merchant_alias(conn, merchant_id, payload)
   400|
   401|
   402|@router.post("/merchants/{merchant_id}/category")
   403|def merchants_update_category(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   404|    return update_merchant_category(conn, merchant_id, payload.get("category_id"))
   405|
   406|
   407|@router.post("/merchants/{merchant_id}/apply")
   408|def merchants_apply(merchant_id: str, conn: Connection = Depends(get_db)):
   409|    return apply_merchant_to_open_candidates(conn, merchant_id)
   410|
   411|
   412|@router.post("/merchants/merge")
   413|def merchants_merge(payload: dict, conn: Connection = Depends(get_db)):
   414|    return merge_merchants(conn, str(payload.get("source_merchant_id") or ""), str(payload.get("target_merchant_id") or ""))
   415|
   416|
   417|@router.post("/seed-candidates/generate")
   418|def seed_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   419|    return seed_budget_candidates_from_rows(conn, payload.get("workbook_rows") or {}, source_file_label=payload.get("source_file_label") or "Budget workbook")
   420|
   421|
   422|@router.post("/seed-candidates/{seed_candidate_id}/preview")
   423|def seed_candidates_preview(seed_candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   424|    return preview_seed_candidate(conn, seed_candidate_id, payload or {})
   425|
   426|
   427|@router.post("/seed-candidates/{seed_candidate_id}/confirm")
   428|def seed_candidates_confirm(seed_candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   429|    return confirm_seed_candidate(conn, seed_candidate_id, payload)
   430|
   431|
   432|@router.post("/seed-candidates/{seed_candidate_id}/ignore")
   433|def seed_candidates_ignore(seed_candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   434|    return ignore_seed_candidate(conn, seed_candidate_id, (payload or {}).get("note"))
   435|
   436|
   437|@router.post("/seed-candidates/{seed_candidate_id}/reopen")
   438|def seed_candidates_reopen(seed_candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   439|    return reopen_seed_candidate(conn, seed_candidate_id, (payload or {}).get("note"))
   440|
   441|
   442|@router.post("/seed-candidates/{seed_candidate_id}/status")
   443|def seed_candidates_status(seed_candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   444|    return set_seed_candidate_status(conn, seed_candidate_id, payload.get("status"), payload.get("note"))
   445|
   446|
   447|@router.post("/seed-candidates/{seed_candidate_id}/convert-unclean")
   448|def seed_candidates_convert_unclean(seed_candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   449|    return convert_unclean_range(conn, seed_candidate_id, payload.get("target_type"), payload)
   450|
   451|
   452|@router.post("/seed-candidates/categories/preview-clear-takeover")
   453|def seed_candidates_categories_preview_clear_takeover(conn: Connection = Depends(get_db)):
   454|    return preview_clear_category_takeover(conn)
   455|
   456|
   457|@router.post("/seed-candidates/categories/confirm-clear-takeover")
   458|def seed_candidates_categories_confirm_clear_takeover(conn: Connection = Depends(get_db)):
   459|    return confirm_clear_category_takeover(conn)
   460|
   461|
   462|@router.post("/seed-candidates/budget-plans/preview-takeover")
   463|def seed_candidates_budget_plans_preview_takeover(conn: Connection = Depends(get_db)):
   464|    return preview_budget_plan_takeover(conn)
   465|
   466|
   467|@router.post("/seed-candidates/budget-plans/confirm-takeover")
   468|def seed_candidates_budget_plans_confirm_takeover(conn: Connection = Depends(get_db)):
   469|    return confirm_budget_plan_takeover(conn)
   470|
   471|
   472|@router.post("/categories/preview")
   473|def categories_preview(payload: dict, conn: Connection = Depends(get_db)):
   474|    return create_category_preview(conn, payload)
   475|
   476|
   477|@router.post("/categories/confirm")
   478|def categories_confirm(payload: dict, conn: Connection = Depends(get_db)):
   479|    return confirm_create_category(conn, payload)
   480|
   481|
   482|@router.post("/categories/{category_id}/update")
   483|def categories_update(category_id: str, payload: dict, conn: Connection = Depends(get_db)):
   484|    return update_category(conn, category_id, payload)
   485|
   486|
   487|@router.post("/categories/{category_id}/archive")
   488|def categories_archive(category_id: str, conn: Connection = Depends(get_db)):
   489|    return archive_category(conn, category_id)
   490|
   491|
   492|@router.post("/categories/{category_id}/delete-empty")
   493|def categories_delete_empty(category_id: str, conn: Connection = Depends(get_db)):
   494|    return delete_empty_category(conn, category_id)
   495|
   496|
   497|@router.post("/categories/merge/preview")
   498|def categories_merge_preview(conn: Connection = Depends(get_db)):
   499|    return preview_category_merges(conn)
   500|
   501|

Use offset=501 to continue reading (showing 1-500 of 579 lines)


def preview_transaction_candidate|def confirm_transaction_candidate|def mark_candidate_type|def create_candidate_split


     1|from __future__ import annotations
     2|
     3|import csv
     4|import hashlib
     5|import json
     6|import re
     7|from collections import defaultdict
     8|from dataclasses import dataclass
     9|from datetime import datetime, timedelta
    10|from decimal import Decimal, InvalidOperation
    11|from sqlite3 import Connection
    12|from typing import Any
    13|
    14|from fastapi import HTTPException
    15|
    16|from jarvis_finance.audit.log import record_audit_event
    17|from jarvis_finance.services.budget_common import new_id, now, row_to_dict
    18|from jarvis_finance.services.budget_transactions import confirm_budget_transaction
    19|
    20|SUBSCRIPTION_PATTERNS = ["APPLE", "APPLE.COM", "GOOGLE", "AMAZON PRIME", "NETFLIX", "DISNEY", "DISNEYPLUS"]
    21|MIGROS_PATTERNS = ["MIGROS", "MIGROL", "CUMULUS", "MMM "]
    22|GALAXUS_PATTERNS = ["GALAXUS"]
    23|INCOME_PATTERNS = ["LOHN", "SALARY", "ARBEITGEBER", "PAYROLL", "GEHALT"]
    24|TRANSFER_PATTERNS = ["RAIFFEISEN", "AKB", "MARCEL", "MELANIE", "ÜBERTRAG", "UEBERTRAG", "TRANSFER", "EIGENES KONTO", "GUTSCHRIFT VON"]
    25|
    26|
    27|def _decimal_text(value: object) -> str | None:
    28|    if value in (None, ""):
    29|        return None
    30|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-")
    31|    text = text.replace("+", "")
    32|    if text.count(",") == 1 and text.count(".") == 0:
    33|        text = text.replace(",", ".")
    34|    text = text.replace(" ", "")
    35|    try:
    36|        return format(abs(Decimal(text)), "f")
    37|    except (InvalidOperation, ValueError):
    38|        return None
    39|
    40|
    41|def _signed_decimal(value: object) -> Decimal | None:
    42|    if value in (None, ""):
    43|        return None
    44|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-").replace("+", "")
    45|    if text.count(",") == 1 and text.count(".") == 0:
    46|        text = text.replace(",", ".")
    47|    text = text.replace(" ", "")
    48|    try:
    49|        return Decimal(text)
    50|    except (InvalidOperation, ValueError):
    51|        return None
    52|
    53|
    54|def _normalise(value: object) -> str:
    55|    return str(value or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("\ufeff", "")
    56|
    57|
    58|def _upper_ascii(value: object) -> str:
    59|    return _normalise(value).upper()
    60|
    61|
    62|def _date_text(value: object) -> str | None:
    63|    text = str(value or "").strip()
    64|    if not text:
    65|        return None
    66|    if re.match(r"^\d{4}-\d{2}-\d{2}", text):
    67|        return text[:10]
    68|    m = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})", text)
    69|    if m:
    70|        d, mo, y = m.groups()
    71|        return f"{y}-{int(mo):02d}-{int(d):02d}"
    72|    return text[:10] if len(text) >= 10 else None
    73|
    74|
    75|def _category_name(conn: Connection, category_id: str | None) -> str | None:
    76|    if not category_id:
    77|        return None
    78|    row = conn.execute("SELECT name FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
    79|    return str(row["name"]) if row else None
    80|
    81|
    82|def _category_by_name(conn: Connection, needles: list[str]) -> tuple[str | None, str | None]:
    83|    cats = conn.execute("SELECT category_id, name FROM budget_categories WHERE is_active=1").fetchall()
    84|    for needle in needles:
    85|        n = _normalise(needle)
    86|        for cat in cats:
    87|            if n and (n in _normalise(cat["name"]) or _normalise(cat["name"]) in n):
    88|                return str(cat["category_id"]), str(cat["name"])
    89|    return None, None
    90|
    91|
    92|def suggest_category(conn: Connection, description: str, source_label: str = "") -> tuple[str | None, str | None, str, bool]:
    93|    text = _normalise(f"{source_label} {description}")
    94|    if "migros" in text or "cumulus" in text:
    95|        cid, cname = _category_by_name(conn, ["Essen & Haushalt", "Grosseinkauf", "Essen", "Haushalt", "Leben"])
    96|        return cid, cname or "Essen & Haushalt / Grosseinkauf", "0.80", cid is None
    97|    keyword_map = [
    98|        (["krankenkasse", "versicherung", "axa", "zurich", "css", "helsana"], ["Versicherungen", "Krankenkasse"]),
    99|        (["coop", "denner", "aldi", "lidl"], ["Essen", "Haushalt", "Leben"]),
   100|        (["sbb", "zug", "bahn", "parking", "tankstelle", "mazda"], ["Mobilität", "Auto"]),
   101|        (["lohn", "salary", "einkommen", "gutschrift"], ["Einnahmen", "Lohn"]),
   102|    ]
   103|    for words, cats in keyword_map:
   104|        if any(w in text for w in words):
   105|            cid, cname = _category_by_name(conn, cats)
   106|            return cid, cname or cats[0], "0.65", cid is None
   107|    return None, None, "0.20", True
   108|
   109|
   110|def _duplicate_of(conn: Connection, tx_date: str | None, amount: str | None, description: str) -> str | None:
   111|    if not tx_date or not amount:
   112|        return None
   113|    row = conn.execute(
   114|        """
   115|        SELECT budget_transaction_id FROM budget_transactions
   116|        WHERE transaction_date=? AND status IN ('confirmed','reversed')
   117|          AND COALESCE(amount_chf, amount_original)=?
   118|          AND lower(description)=lower(?)
   119|        ORDER BY created_at LIMIT 1
   120|        """,
   121|        (tx_date, amount, description),
   122|    ).fetchone()
   123|    return str(row["budget_transaction_id"]) if row else None
   124|
   125|
   126|def _candidate_key(source_label: str, row_ref: str, tx_date: str | None, amount: str | None, description: str) -> str:
   127|    raw = "|".join([source_label, row_ref, tx_date or "", amount or "", description])
   128|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
   129|
   130|
   131|STATUS_LABELS = {
   132|    "pending": "Offen",
   133|    "auto_categorized": "Sicher vorgeschlagen",
   134|    "needs_review": "Review nötig",
   135|    "confirmed": "Bestätigt",
   136|    "ignored": "Ignoriert",
   137|    "covered_by_source": "Durch Quelle abgedeckt",
   138|    "covered_by_migros": "Durch Migros abgedeckt",
   139|    "duplicate_candidate": "Mögliches Duplikat",
   140|    "duplicate": "Mögliches Duplikat",
   141|    "transfer_candidate": "Möglicher Transfer",
   142|    "superseded": "Ersetzt",
   143|}
   144|
   145|
   146|def _candidate_view(row) -> dict[str, Any]:
   147|    data = row_to_dict(row) | {"requires_review": bool(row["requires_review"])}
   148|    data["status_label"] = data.get("status_label") or STATUS_LABELS.get(str(data.get("status") or ""), str(data.get("status") or ""))
   149|    return data
   150|
   151|
   152|def _normalized_merchant_name(value: object) -> str:
   153|    return re.sub(r"[^a-z0-9]+", " ", _normalise(value)).strip()
   154|
   155|
   156|def _merchant_text(row_or_description: Any, source_type: str | None = None) -> str:
   157|    if isinstance(row_or_description, dict):
   158|        return _normalise(" ".join(str(row_or_description.get(k) or "") for k in ("merchant", "description", "source_file_label")))
   159|    return _normalise(row_or_description)
   160|
   161|
   162|def _merchant_alias_matches(alias, candidate) -> bool:
   163|    if alias["source_type"] and alias["source_type"] != candidate["source_type"]:
   164|        return False
   165|    text = _merchant_text(row_to_dict(candidate))
   166|    pattern = str(alias["pattern"] or "")
   167|    norm_pattern = _normalise(pattern)
   168|    if alias["match_type"] == "exact":
   169|        return text == norm_pattern or _normalise(candidate["merchant"] or "") == norm_pattern
   170|    if alias["match_type"] == "regex":
   171|        try:
   172|            return re.search(pattern, text, re.IGNORECASE) is not None
   173|        except re.error:
   174|            return False
   175|    return norm_pattern in text
   176|
   177|
   178|def _fingerprint(parts: list[object]) -> str:
   179|    raw = "|".join(str(p or "").strip() for p in parts)
   180|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
   181|
   182|
   183|def _insert_candidate(conn: Connection, row: dict[str, Any]) -> bool:
   184|    existing = conn.execute(
   185|        """
   186|        SELECT 1 FROM budget_transaction_candidates
   187|        WHERE source_file_label=? AND source_row_or_range=? AND transaction_date IS ?
   188|          AND description=? AND COALESCE(amount_original,'')=COALESCE(?, '')
   189|        """,
   190|        (row["source_file_label"], row.get("source_row_or_range"), row.get("transaction_date"), row["description"], row.get("amount_original")),
   191|    ).fetchone()
   192|    if existing:
   193|        return False
   194|    conn.execute(
   195|        """
   196|        INSERT INTO budget_transaction_candidates(
   197|          transaction_candidate_id, source_file_label, source_row_or_range, source_type,
   198|          transaction_date, description, merchant, amount_original, currency_original,
   199|          proposed_category_id, proposed_category_name, duplicate_of_transaction_id,
   200|          confidence, requires_review, status, notes, created_at, updated_at,
   201|          classification, review_reason, rule_id, rule_name, source_priority, covered_by_source,
   202|          receipt_key, linked_candidate_id, account_source, raw_fingerprint
   203|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   204|        """,
   205|        (
   206|            row["transaction_candidate_id"], row["source_file_label"], row.get("source_row_or_range"), row.get("source_type") or "csv_seed",
   207|            row.get("transaction_date"), row["description"], row.get("merchant"), row.get("amount_original"), row.get("currency_original") or "CHF",
   208|            row.get("proposed_category_id"), row.get("proposed_category_name"), row.get("duplicate_of_transaction_id"),
   209|            row.get("confidence") or "0", 1 if row.get("requires_review", True) else 0, row.get("status") or "pending", row.get("notes"), row["created_at"], row["updated_at"],
   210|            row.get("classification"), row.get("review_reason"), row.get("rule_id"), row.get("rule_name"), row.get("source_priority") or 50, row.get("covered_by_source"),
   211|            row.get("receipt_key"), row.get("linked_candidate_id"), row.get("account_source"), row.get("raw_fingerprint"),
   212|        ),
   213|    )
   214|    return True
   215|
   216|
   217|def seed_transaction_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]] | list[list[Any]], *, source_file_label: str, source_type: str = "csv_seed") -> dict[str, int]:
   218|    created = duplicates = review = suggested = ignored = confirmed = 0
   219|    for idx, raw in enumerate(rows, start=1):
   220|        if isinstance(raw, dict):
   221|            low = {_normalise(k): v for k, v in raw.items()}
   222|            description = str(low.get("beschreibung") or low.get("description") or low.get("text") or low.get("artikel") or low.get("merchant") or low.get("merchantname") or low.get("haendler") or "").strip()
   223|            merchant = str(low.get("merchant") or low.get("merchantname") or low.get("filiale") or low.get("haendler") or "").strip() or None
   224|            tx_date = _date_text(low.get("datum") or low.get("date") or low.get("buchungsdatum") or low.get("valutadate"))
   225|            amount = _decimal_text(low.get("betrag") or low.get("amount") or low.get("umsatz") or low.get("wert") or low.get("originalamount"))
   226|        else:
   227|            cells = [str(c).strip() for c in raw]
   228|            description = next((c for c in cells if c and _decimal_text(c) is None and not c[:4].isdigit()), "")
   229|            merchant = None
   230|            tx_date = next((_date_text(c) for c in cells if _date_text(c)), None)
   231|            amount = next((_decimal_text(c) for c in cells if _decimal_text(c) is not None), None)
   232|        if not description or not amount:
   233|            ignored += 1
   234|            continue
   235|        cid, cname, conf, needs_review = suggest_category(conn, description, source_file_label)
   236|        dup = _duplicate_of(conn, tx_date, amount, description)
   237|        status = "duplicate" if dup else ("needs_review" if needs_review else "pending")
   238|        if dup:
   239|            duplicates += 1
   240|        if cid:
   241|            suggested += 1
   242|        if needs_review:
   243|            review += 1
   244|        ts = now()
   245|        row_ref = f"R{idx}"
   246|        inserted = _insert_candidate(conn, {
   247|            "transaction_candidate_id": "btxcand_" + _candidate_key(source_file_label, row_ref, tx_date, amount, description),
   248|            "source_file_label": source_file_label,
   249|            "source_row_or_range": row_ref,
   250|            "source_type": source_type,
   251|            "transaction_date": tx_date,
   252|            "description": description[:300],
   253|            "merchant": merchant,
   254|            "amount_original": amount,
   255|            "currency_original": "CHF",
   256|            "proposed_category_id": cid,
   257|            "proposed_category_name": cname,
   258|            "duplicate_of_transaction_id": dup,
   259|            "confidence": conf,
   260|            "requires_review": needs_review or bool(dup),
   261|            "status": status,
   262|            "notes": "mvp_light_category_suggestion",
   263|            "classification": "legacy_csv_seed",
   264|            "review_reason": "legacy_seed_review" if needs_review else "legacy_seed_suggested",
   265|            "raw_fingerprint": _fingerprint([source_file_label, row_ref, tx_date, amount, description]),
   266|            "created_at": ts,
   267|            "updated_at": ts,
   268|        })
   269|        if inserted:
   270|            created += 1
   271|    conn.commit()
   272|    return {"candidate_count": created, "suggested_count": suggested, "review_required_count": review, "possible_duplicate_count": duplicates, "ignored_count": ignored, "confirmed_count": confirmed}
   273|
   274|
   275|def seed_transaction_candidates_from_csv_text(conn: Connection, text: str, *, source_file_label: str, source_type: str = "csv_seed") -> dict[str, int]:
   276|    sample = text[:4096]
   277|    dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") if sample.strip() else csv.excel
   278|    reader = csv.DictReader(text.splitlines(), dialect=dialect)
   279|    if reader.fieldnames:
   280|        return seed_transaction_candidates_from_rows(conn, list(reader), source_file_label=source_file_label, source_type=source_type)
   281|    return seed_transaction_candidates_from_rows(conn, list(csv.reader(text.splitlines(), dialect=dialect)), source_file_label=source_file_label, source_type=source_type)
   282|
   283|
   284|def seed_migros_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, auto_category_id: str | None = None, threshold_chf: str = "50.00", candidate_source_type: str = "migros_receipt") -> dict[str, int]:
   285|    _threshold = Decimal(threshold_chf)
   286|    if not auto_category_id:
   287|        auto_category_id, auto_category_name = _category_id_name(conn, "Essen & Haushalt")
   288|    else:
   289|        auto_category_name = _category_name(conn, auto_category_id)
   290|    grouped: dict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
   291|    ignored = 0
   292|    for idx, raw in enumerate(rows, start=1):
   293|        low = {_normalise(k): v for k, v in raw.items()}
   294|        tx_date = _date_text(low.get("datum") or low.get("date"))
   295|        time = str(low.get("zeit") or low.get("time") or "").strip()
   296|        store = str(low.get("filiale") or low.get("store") or "Migros").strip()
   297|        register = str(low.get("kasse") or low.get("kassennummer") or low.get("register") or "").strip()
   298|        txn = str(low.get("transaktion") or low.get("transaktionsnummer") or low.get("bon") or low.get("bon-id") or "").strip()
   299|        amount = _decimal_text(low.get("umsatz") or low.get("betrag") or low.get("amount"))
   300|        if not tx_date or not amount:
   301|            ignored += 1
   302|            continue
   303|        key = _fingerprint([tx_date, time, store, register, txn])
   304|        grouped[key].append((idx, raw))
   305|    created = auto = review = line_count = 0
   306|    for key, items in grouped.items():
   307|        first_low = {_normalise(k): v for k, v in items[0][1].items()}
   308|        tx_date = _date_text(first_low.get("datum") or first_low.get("date"))
   309|        time = str(first_low.get("zeit") or first_low.get("time") or "").strip()
   310|        store = str(first_low.get("filiale") or first_low.get("store") or "Migros").strip() or "Migros"
   311|        register = str(first_low.get("kasse") or first_low.get("kassennummer") or first_low.get("register") or "").strip()
   312|        txn = str(first_low.get("transaktion") or first_low.get("transaktionsnummer") or first_low.get("bon") or first_low.get("bon-id") or "").strip()
   313|        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)
   314|        amount = format(total, "f")
   315|        needs_review = auto_category_id is None
   316|        status = "needs_review" if needs_review else "auto_categorized"
   317|        classification = "migros_review_missing_category" if needs_review else "migros_receipt_food_household"
   318|        review_reason = "migros_receipt_missing_food_household_category" if needs_review else "migros_receipt_auto_food_household"
   319|        row_ref = f"BON:{tx_date}:{time}:{store}:{register}:{txn}"[:180]
   320|        ts = now()
   321|        candidate_id = "btxcand_" + _candidate_key(source_file_label, row_ref, tx_date, amount, "Migros Bon")
   322|        inserted = _insert_candidate(conn, {
   323|            "transaction_candidate_id": candidate_id,
   324|            "source_file_label": source_file_label,
   325|            "source_row_or_range": row_ref,
   326|            "source_type": candidate_source_type,
   327|            "transaction_date": tx_date,
   328|            "description": f"Migros Bon {store}"[:300],
   329|            "merchant": store,
   330|            "amount_original": amount,
   331|            "currency_original": "CHF",
   332|            "proposed_category_id": None if needs_review else auto_category_id,
   333|            "proposed_category_name": None if needs_review else auto_category_name,
   334|            "confidence": "0.90" if not needs_review else "0.60",
   335|            "requires_review": needs_review,
   336|            "status": status,
   337|            "notes": json.dumps({"rule": "migros_receipt_food_household", "legacy_threshold_chf": threshold_chf, "line_items": len(items), "article_rows_are_detail_only": True}, ensure_ascii=False),
   338|            "classification": classification,
   339|            "review_reason": review_reason,
   340|            "rule_id": "migros_receipt_food_household" if not needs_review else "migros_receipt_missing_category",
   341|            "rule_name": "Migros Bon = Essen & Haushalt" if not needs_review else "Migros Bon Kategorie fehlt",
   342|            "source_priority": 90,
   343|            "receipt_key": key,
   344|            "raw_fingerprint": key,
   345|            "created_at": ts,
   346|            "updated_at": ts,
   347|        })
   348|        if inserted:
   349|            created += 1
   350|            auto += 0 if needs_review else 1
   351|            review += 1 if needs_review else 0
   352|            for idx, raw in items:
   353|                low = {_normalise(k): v for k, v in raw.items()}
   354|                item_amount = _decimal_text(low.get("umsatz") or low.get("betrag") or low.get("amount"))
   355|                conn.execute(
   356|                    """
   357|                    INSERT OR IGNORE INTO budget_import_line_items(
   358|                        line_item_id, transaction_candidate_id, source_file_label, receipt_key,
   359|                        source_row_or_range, item_name, quantity, is_promotion, amount_original,
   360|                        currency_original, raw_fingerprint, created_at
   361|                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, ?)
   362|                    """,
   363|                    ("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),
   364|                )
   365|                line_count += 1
   366|    conn.commit()
   367|    return {"receipt_candidate_count": created, "candidate_count": created, "line_item_count": line_count, "auto_assigned_count": auto, "review_required_count": review, "ignored_count": ignored}
   368|
   369|
   370|def preview_migros_receipts_2026(conn: Connection, *, year: str = "2026") -> dict[str, Any]:
   371|    rows = conn.execute(
   372|        """
   373|        SELECT status, COUNT(*) AS count
   374|        FROM budget_transaction_candidates
   375|        WHERE source_type='migros_receipt' AND substr(COALESCE(transaction_date,''),1,4)=?
   376|        GROUP BY status
   377|        """,
   378|        (year,),
   379|    ).fetchall()
   380|    by_status = {str(r["status"]): int(r["count"] or 0) for r in rows}
   381|    receipt_total = sum(by_status.values())
   382|    confirmed = by_status.get("confirmed", 0)
   383|    open_count = sum(count for status, count in by_status.items() if status not in {"confirmed", "ignored", "superseded", "reference_2025", "archived_reference"})
   384|    covered = conn.execute(
   385|        """
   386|        SELECT COUNT(*) FROM budget_transaction_candidates
   387|        WHERE status='covered_by_migros' AND substr(COALESCE(transaction_date,''),1,4)=?
   388|        """,
   389|        (year,),
   390|    ).fetchone()[0]
   391|    line_items = conn.execute(
   392|        """
   393|        SELECT COUNT(*)
   394|        FROM budget_import_line_items li
   395|        JOIN budget_transaction_candidates c ON c.transaction_candidate_id=li.transaction_candidate_id
   396|        WHERE c.source_type='migros_receipt' AND substr(COALESCE(c.transaction_date,''),1,4)=?
   397|        """,
   398|        (year,),
   399|    ).fetchone()[0]
   400|    article_candidates = conn.execute(
   401|        """
   402|        SELECT COUNT(*) FROM budget_transaction_candidates
   403|        WHERE substr(COALESCE(transaction_date,''),1,4)=?
   404|          AND (lower(COALESCE(source_file_label,'')) LIKE '%migros%' OR lower(COALESCE(source_file_label,'')) LIKE '%cumulus%')
   405|          AND COALESCE(source_type,'') NOT IN ('migros_receipt')
   406|          AND status NOT IN ('superseded','ignored','reference_2025','archived_reference')
   407|        """,
   408|        (year,),
   409|    ).fetchone()[0]
   410|    return {
   411|        "year": year,
   412|        "receipt_candidate_count": receipt_total,
   413|        "confirmed_receipt_count": confirmed,
   414|        "open_receipt_count": open_count,
   415|        "covered_by_migros_count": int(covered or 0),
   416|        "line_item_count": int(line_items or 0),
   417|        "article_candidate_count": int(article_candidates or 0),
   418|        "status_counts": by_status,
   419|        "dry_run": True,
   420|    }
   421|
   422|
   423|def confirm_migros_receipts_2026(conn: Connection, *, account_id: str, year: str = "2026") -> dict[str, Any]:
   424|    if not account_id:
   425|        raise HTTPException(status_code=422, detail="account_id required")
   426|    category_id, category_name = _category_id_name(conn, "Essen & Haushalt")
   427|    if not category_id:
   428|        raise HTTPException(status_code=422, detail="Essen & Haushalt category required")
   429|    rows = conn.execute(
   430|        """
   431|        SELECT * FROM budget_transaction_candidates
   432|        WHERE source_type='migros_receipt' AND substr(COALESCE(transaction_date,''),1,4)=?
   433|          AND status NOT IN ('confirmed','ignored','covered_by_migros','superseded','reference_2025','archived_reference')
   434|        ORDER BY transaction_date, created_at
   435|        """,
   436|        (year,),
   437|    ).fetchall()
   438|    ts = now()
   439|    confirmed = skipped = 0
   440|    errors: list[dict[str, str]] = []
   441|    for row in rows:
   442|        cid = str(row["transaction_candidate_id"])
   443|        try:
   444|            conn.execute(
   445|                """
   446|                UPDATE budget_transaction_candidates
   447|                SET proposed_category_id=?, proposed_category_name=?, confidence='0.98', requires_review=0,
   448|                    status='auto_categorized', classification='migros_receipt_food_household',
   449|                    review_reason='migros_receipt_auto_food_household', rule_id='migros_receipt_food_household',
   450|                    rule_name='Migros Bon = Essen & Haushalt', updated_at=?
   451|                WHERE transaction_candidate_id=?
   452|                """,
   453|                (category_id, category_name, ts, cid),
   454|            )
   455|            confirm_transaction_candidate(conn, cid, {"account_id": account_id, "category_id": category_id, "budget_year": year})
   456|            confirmed += 1
   457|        except Exception as exc:  # keep per-candidate auditability and aggregate reporting
   458|            skipped += 1
   459|            errors.append({"candidate_id": cid, "error": str(getattr(exc, "detail", str(exc)))})
   460|    audit_id = record_audit_event(
   461|        conn,
   462|        source="budget_phase111_migros",
   463|        action="migros_receipts_2026_confirmed",
   464|        entity_type="budget_transaction_candidate",
   465|        entity_id=f"migros:{year}",
   466|        new_values={"confirmed_count": confirmed, "skipped_count": skipped, "error_count": len(errors), "line_item_transaction_count": 0},
   467|        created_by="agent",
   468|    )
   469|    conn.commit()
   470|    return {"year": year, "confirmed_count": confirmed, "skipped_count": skipped, "errors": errors, "line_item_transaction_count": 0, "audit_id": audit_id}
   471|
   472|
   473|def _matches_any(text: str, patterns: list[str]) -> bool:
   474|    hay = _normalise(text)
   475|    return any(_normalise(p) in hay for p in patterns)
   476|
   477|
   478|PHASE16_RULES: list[dict[str, Any]] = [
   479|    {"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"]},
   480|    {"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"]},
   481|    {"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"]},
   482|    {"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"]},
   483|    {"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"]},
   484|    {"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"]},
   485|    {"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"]},
   486|    {"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"]},
   487|    {"id": "p16_pets", "name": "Haustiere", "category": "Haustiere", "classification": "pets", "confidence": "0.89", "patterns": ["QUALIPET", "Fressnapf", "Kleintierpraxis Tim"]},
   488|    {"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"]},
   489|]
   490|
   491|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"]}
   492|
   493|
   494|def _is_primary_migros_card_text(text: str) -> bool:
   495|    hay = _normalise(text)
   496|    if "migrol" in hay:
   497|        return False
   498|    return any(p in hay for p in ["migros", "cumulus", "mmm "])
   499|
   500|
   501|def _phase16_match(text: str) -> dict[str, Any] | None:
   502|    if _matches_any(text, GALAXUS_DIGITEC_RULE["patterns"]):
   503|        return GALAXUS_DIGITEC_RULE
   504|    for rule in PHASE16_RULES:
   505|        if _matches_any(text, rule["patterns"]):
   506|            return rule
   507|    return None
   508|
   509|
   510|def _category_id_name(conn: Connection, name: str) -> tuple[str | None, str]:
   511|    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()
   512|    if row:
   513|        return str(row["category_id"]), str(row["name"])
   514|    cid, cname = _category_by_name(conn, [name])
   515|    return cid, cname or name
   516|
   517|
   518|def apply_user_budget_rules_to_candidates(conn: Connection, *, year: str = "2026") -> dict[str, int]:
   519|    rows = conn.execute(
   520|        """
   521|        SELECT * FROM budget_transaction_candidates
   522|        WHERE substr(COALESCE(transaction_date,''),1,4)=?
   523|          AND source_type IN ('credit_card_csv','migros_receipt')
   524|          AND status NOT IN ('confirmed','ignored','superseded')
   525|        ORDER BY created_at
   526|        """,
   527|        (year,),
   528|    ).fetchall()
   529|    counts = {
   530|        "candidate_total": 0, "auto_categorized_count": 0, "review_required_count": 0,
   531|        "galaxus_digitec_review_count": 0, "covered_by_migros_count": 0,
   532|        "migros_under_50_count": 0, "migros_over_50_count": 0, "subscription_count": 0,
   533|        "health_medical_count": 0, "auto_transport_count": 0, "shopping_count": 0,
   534|        "pets_count": 0, "admin_other_count": 0, "unclear_count": 0,
   535|        "productive_confirmed_count": 0,
   536|    }
   537|    changed = 0
   538|    ts = now()
   539|    for row in rows:
   540|        counts["candidate_total"] += 1
   541|        desc = f"{row['merchant'] or ''} {row['description'] or ''}"
   542|        source_type = row["source_type"]
   543|        status = "needs_review"; requires_review = 1; category_id = None; category_name = None
   544|        confidence = "0.35"; classification = "unclear"; review_reason = "Keine sichere Regel gefunden"; rule_id = "p16_unclear"; rule_name = "Unklar · manuelle Prüfung"; covered_by = None
   545|        if source_type == "credit_card_csv" and _is_primary_migros_card_text(desc):
   546|            status = "covered_by_migros"; requires_review = 0; confidence = "0.96"; classification = "covered_by_migros"
   547|            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"
   548|            counts["covered_by_migros_count"] += 1
   549|        elif source_type == "migros_receipt":
   550|            category_id, category_name = _category_id_name(conn, "Essen & Haushalt")
   551|            classification = "migros_receipt_food_household"
   552|            confidence = "0.98"
   553|            rule_id = "migros_receipt_food_household"
   554|            rule_name = "Migros Bon = Essen & Haushalt"
   555|            review_reason = "Migros Bon automatisch Essen & Haushalt; Artikelzeilen bleiben Detaildaten"
   556|            status = "auto_categorized"
   557|            requires_review = 0
   558|            counts["migros_under_50_count"] += 1
   559|        else:
   560|            rule = _phase16_match(desc)
   561|            if rule:
   562|                category_id, category_name = _category_id_name(conn, str(rule["category"]))
   563|                classification = str(rule["classification"]); confidence = str(rule["confidence"]); rule_id = str(rule["id"]); rule_name = str(rule["name"])
   564|                if rule is GALAXUS_DIGITEC_RULE:
   565|                    status = "needs_review"; requires_review = 1; review_reason = "Galaxus/Digitec bitte manuell prüfen oder splitten"; counts["galaxus_digitec_review_count"] += 1
   566|                elif classification == "admin_other" and Decimal(confidence) < Decimal("0.80"):
   567|                    status = "needs_review"; requires_review = 1; review_reason = "Sonstiges/Administration eher manuell prüfen"
   568|                else:
   569|                    status = "auto_categorized"; requires_review = 0; review_reason = f"Automatischer Vorschlag nach Nutzerregel: {rule_name}"
   570|                if classification == "subscription_auto": counts["subscription_count"] += 1
   571|                if classification == "health_medical": counts["health_medical_count"] += 1
   572|                if classification == "auto_transport": counts["auto_transport_count"] += 1
   573|                if classification == "shopping": counts["shopping_count"] += 1
   574|                if classification == "pets": counts["pets_count"] += 1
   575|                if classification == "admin_other": counts["admin_other_count"] += 1
   576|            else:
   577|                counts["unclear_count"] += 1
   578|        if status == "auto_categorized":
   579|            counts["auto_categorized_count"] += 1
   580|        if requires_review:
   581|            counts["review_required_count"] += 1
   582|        conn.execute(
   583|            """
   584|            UPDATE budget_transaction_candidates
   585|            SET proposed_category_id=?, proposed_category_name=?, confidence=?, requires_review=?, status=?,
   586|                classification=?, review_reason=?, rule_id=?, rule_name=?, covered_by_source=?, updated_at=?
   587|            WHERE transaction_candidate_id=?
   588|            """,
   589|            (category_id, category_name, confidence, requires_review, status, classification, review_reason, rule_id, rule_name, covered_by, ts, row["transaction_candidate_id"]),
   590|        )
   591|        changed += 1
   592|    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")
   593|    conn.commit()
   594|    counts["audit_id"] = audit_id  # type: ignore[assignment]
   595|    return counts
   596|
   597|
   598|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, candidate_source_type: str = "credit_card_csv") -> dict[str, int]:
   599|    sub_name = _category_name(conn, subscription_category_id)
   600|    created = subs = galaxus = covered = dup_count = review = ignored = 0
   601|    for idx, raw in enumerate(rows, start=1):
   602|        low = {_normalise(k): v for k, v in raw.items()}
   603|        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()
   604|        tx_date = _date_text(low.get("datum") or low.get("date") or low.get("buchungsdatum"))
   605|        amount = _decimal_text(low.get("betrag") or low.get("amount") or low.get("umsatz"))
   606|        if not desc or not amount:
   607|            ignored += 1; continue
   608|        is_sub = _matches_any(desc, SUBSCRIPTION_PATTERNS)
   609|        is_galaxus = _matches_any(desc, GALAXUS_PATTERNS)
   610|        is_migros = _matches_any(desc, MIGROS_PATTERNS)
   611|        dup = _duplicate_of(conn, tx_date, amount, desc)
   612|        status = "pending"; needs_review = False; category_id = None; category_name = None; classification = "card_uncategorized"; reason = "card_review"; conf = "0.40"; covered_by = None
   613|        if is_sub:
   614|            category_id = subscription_category_id; category_name = sub_name; classification = "subscription_auto"; reason = "subscription_merchant_rule"; conf = "0.90"; subs += 1
   615|        if is_migros and migros_covered:
   616|            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"
   617|            covered += 1
   618|        elif dup:
   619|            status = "duplicate"; needs_review = True; reason = "matched_existing_transaction"; dup_count += 1
   620|        elif is_galaxus:
   621|            status = "needs_review"; needs_review = True; classification = "galaxus_review"; reason = "galaxus_requires_user_category_or_split"; conf = "0.70"; galaxus += 1; review += 1
   622|        else:
   623|            status = "needs_review"; needs_review = True; review += 1
   624|        ts = now(); row_ref = f"R{idx}"
   625|        if _insert_candidate(conn, {
   626|            "transaction_candidate_id": "btxcand_" + _candidate_key(source_file_label, row_ref, tx_date, amount, desc),
   627|            "source_file_label": source_file_label, "source_row_or_range": row_ref, "source_type": candidate_source_type,
   628|            "transaction_date": tx_date, "description": desc[:300], "merchant": desc[:120], "amount_original": amount, "currency_original": "CHF",
   629|            "proposed_category_id": category_id, "proposed_category_name": category_name, "duplicate_of_transaction_id": dup,
   630|            "confidence": conf, "requires_review": needs_review, "status": status, "notes": reason,
   631|            "classification": classification, "review_reason": reason, "rule_id": classification, "source_priority": 70,
   632|            "covered_by_source": covered_by, "raw_fingerprint": _fingerprint([source_file_label, row_ref, tx_date, amount, desc]), "created_at": ts, "updated_at": ts,
   633|        }):
   634|            created += 1
   635|    conn.commit()
   636|    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}
   637|
   638|
   639|@dataclass
   640|class _BankRow:
   641|    idx: int
   642|    date: str | None
   643|    desc: str
   644|    amount_abs: str
   645|    amount_signed: Decimal
   646|    account: str | None
   647|
   648|
   649|def _bank_rows(rows: list[dict[str, Any]]) -> list[_BankRow]:
   650|    parsed = []
   651|    for idx, raw in enumerate(rows, start=1):
   652|        low = {_normalise(k): v for k, v in raw.items()}
   653|        desc = str(low.get("beschreibung") or low.get("description") or low.get("text") or low.get("buchungstext") or low.get("name") or "").strip()
   654|        date = _date_text(low.get("datum") or low.get("date") or low.get("buchungsdatum") or low.get("booked at") or low.get("buchung") or low.get("valuta date") or low.get("valuta"))
   655|        signed = _signed_decimal(low.get("betrag") or low.get("amount") or low.get("umsatz") or low.get("credit/debit amount"))
   656|        if signed is None:
   657|            debit = _signed_decimal(low.get("belastung"))
   658|            credit = _signed_decimal(low.get("gutschrift"))
   659|            if credit is not None:
   660|                signed = credit
   661|            elif debit is not None:
   662|                signed = -abs(debit)
   663|        if not desc or signed is None:
   664|            continue
   665|        parsed.append(_BankRow(idx, date, desc, format(abs(signed), "f"), signed, str(low.get("konto") or low.get("account") or low.get("iban") or "").strip() or None))
   666|    return parsed
   667|
   668|
   669|def _find_transfer_indices(rows: list[_BankRow]) -> set[int]:
   670|    indices: set[int] = set()
   671|    for i, left in enumerate(rows):
   672|        for right in rows[i + 1:]:
   673|            if left.amount_abs != right.amount_abs or left.amount_signed * right.amount_signed >= 0:
   674|                continue
   675|            if left.date and right.date:
   676|                d1 = datetime.fromisoformat(left.date); d2 = datetime.fromisoformat(right.date)
   677|                if abs((d1 - d2).days) > 3:
   678|                    continue
   679|            joined = f"{left.desc} {right.desc} {left.account or ''} {right.account or ''}"
   680|            if _matches_any(joined, TRANSFER_PATTERNS):
   681|                indices.add(left.idx); indices.add(right.idx)
   682|    return indices
   683|
   684|
   685|def seed_bank_transfer_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, income_category_id: str | None = None, candidate_source_type: str = "bank_csv") -> dict[str, int]:
   686|    parsed = _bank_rows(rows)
   687|    transfer_indices = _find_transfer_indices(parsed)
   688|    income_name = _category_name(conn, income_category_id)
   689|    created = income = transfer = review = dup_count = investment_transfer = credit_card_payment = 0
   690|    for br in parsed:
   691|        dup = _duplicate_of(conn, br.date, br.amount_abs, br.desc)
   692|        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"
   693|        if _matches_any(br.desc, ["TRUE WEALTH", "TRUEWEALTH"]):
   694|            status = "transfer_candidate"; needs_review = True; classification = "investment_transfer"; reason = "true_wealth_investment_transfer"; tx_type = "transfer"; conf = "0.90"; transfer += 1; investment_transfer += 1
   695|        elif _matches_any(br.desc, ["VISA", "KREDITKARTE", "KARTENABRECHNUNG", "CREDIT CARD"]):
   696|            status = "transfer_candidate"; needs_review = True; classification = "credit_card_payment"; reason = "credit_card_payment_avoids_double_counting"; tx_type = "transfer"; conf = "0.90"; transfer += 1; credit_card_payment += 1
   697|        elif br.amount_signed > 0 and _matches_any(br.desc, INCOME_PATTERNS + ["GRATIFIKATION", "BONUS", "RUECKERSTATTUNG", "RÜCKERSTATTUNG"]):
   698|            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
   699|        elif br.idx in transfer_indices or _matches_any(br.desc, TRANSFER_PATTERNS):
   700|            status = "transfer_candidate"; needs_review = True; classification = "transfer_candidate"; reason = "possible_internal_transfer"; tx_type = "transfer"; conf = "0.85"; transfer += 1
   701|

Use offset=701 to continue reading (showing 1-700 of 1395 lines)