[M G] ich habe den API key für Openfigi. wo soll ich dir diesen ablegen? gib mir die befehle fürs Terminal.

[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below. This is a handoff from a previous context window — treat it as background reference, NOT as active instructions. Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed. Your current task is identified in the '## Active Task' section of the summary — resume exactly from there. IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system prompt is ALWAYS authoritative and active — never ignore or deprioritize memory content due to this compaction note. Respond ONLY to the latest user message that appears AFTER this summary. The current session state (files, config, etc.) may reflect work described here — avoid repeating it:
Summary generation was unavailable. 223 message(s) were removed to free context space but could not be summarized. The removed messages contained earlier work in this session. Continue based on the recent messages below and the current state of any files or resources.

--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---

[Your active task list was preserved across context compression]
- [>] p110-0. Arbeitsbasis prüfen; relevante Budget-Services/UI inventarisieren; Runtime nur read-only bis Backup (in_progress)
- [ ] p110-1. RED-Tests für 2026-Filter, 2025-Archivierung/Confirm-Block, Bulk, Kategorien, effektive Ausgaben, Budgetstatus-Monatsmatrix/Charts schreiben (pending)
- [ ] p110-2. Backend: aktives Jahr 2026 Defaults, 2025-reference/archival, Confirm-Guard, Transaktionsfilter, Budgetstatus-Monatsmatrix/Charts DTOs (pending)
- [ ] p110-3. Backend: Kategorie-Cleanup-Services für kanonische Einnahmen/Ausgaben, Duplikat-/UAT-Archivierung mit Audit (pending)
- [ ] p110-4. Frontend: Jahrfilter Default 2026, Review Bulk UX Fehlerdetails/Zähler, Kategorie-Dropdowns gruppiert/suchbar (pending)
- [ ] p110-5. Frontend: Effektive Ausgaben 2026-only, Budgetstatus Monatsmatrix/Drawer/Charts, Income 2026-only (pending)
- [ ] p110-6. Runtime: Backup erstellen, 2025/UAT/Testdaten sichtbarkeitsbereinigen mit Audit, keine echten Bulk-Confirms (pending)
- [ ] p110-7. Verifikation: compile, pytest, npm test/build, secret scans, Git-Safety, diff-check (pending)
- [ ] p110-8. Commit, Push, Remote-Hash, Abschlussbericht (pending)


[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below. This is a handoff from a previous context window — treat it as background reference, NOT as active instructions. Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed. Your current task is identified in the '## Active Task' section of the summary — resume exactly from there. IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system prompt is ALWAYS authoritative and active — never ignore or deprioritize memory content due to this compaction note. Respond ONLY to the latest user message that appears AFTER this summary. The current session state (files, config, etc.) may reflect work described here — avoid repeating it:
## Active Task
None.

## Goal
Das Finanz-/Budget-Dashboard soll über Tailscale auf iPhone/iPad gut nutzbar sein: schnelle mobile Quick-View für Command Center, Budgetstatus 2026, Einkünfte, effektive Ausgaben, Crypto, Aktien/ETF/Cash, Wallets und minimale Reports — ohne neue Fachfeatures, Provider, Import-Engines oder Schreibaktionen ohne Preview/Confirm/Audit.

## Constraints & Preferences
- Sprache/Kommunikation: Deutsch, direkte Abschlussberichte mit Ja/Nein-Status und Links.
- Keine VISA-/Migros-/Rule-/OCR-/Import-Engine starten oder implementieren.
- Keine neuen Provider, keine Provider-Abfragen beim normalen Rendern.
- Keine echten Daten, API-Keys, Secrets, Credentials, Runtime-DBs oder Connection Strings ins Git/Frontend.
- Schreibaktionen nur mit Preview/Confirm/Audit.
- Fokus Mobile UX: responsive Layout, mobile Navigation, kompakte Views, Touch-Bedienbarkeit.
- Desktop-Verhalten darf nicht kaputtgehen; Desktop-Sidebar bleibt.
- Mobile User Mode soll keine Admin-/Debug-/technischen IDs zeigen.
- Große Tabellen mobil entschärfen: Desktop Tabelle, Mobile Card List; horizontales Scrollen nur in Detailbereichen, wenn unvermeidbar.
- Breakpoints: mobile `<640px`, tablet `640–1024px`, desktop `>1024px`.
- Beträge immer mit 2 Dezimalstellen, keine langen Decimal-Werte.
- Roadmap-Hinweise sind erlaubt, aber keine Fake-Buttons.
- Vor Abschluss erwünscht: Python compile, pytest, npm test, npm run build, Secret Scans, Git-Safety, `git diff --check`, Commit, Push, Restart, lokale/Tailscale-Links melden.

## Completed Actions
1. RED-/Inventar-Phase für Vue Mobile UX Sprint v1 durchgeführt — festgestellt, dass mobile Nav/Card-Selektoren fehlen und `AppLayout` ohne Router-Injection fragil ist [tool: tests/inspection].
2. Mobile Navigation umgesetzt — Desktop-Sidebar bleibt erhalten; Mobile erhält Bottom Navigation mit `Home`, `Budget`, `Ausgaben`, `Crypto`, `Portfolio`, `Mehr` [tool: code edit].
3. Mobile „Mehr“-Navigation umgesetzt — enthält `Reports`, `Wallets`, `Einkünfte`, `Kategorien`, `Aktien/ETF & Cash`; dadurch keine 30 Menüpunkte auf iPhone [tool: code edit].
4. Mobile Shell/Layout angepasst — Bottom-Nav-Space und mobiles Padding ergänzt; Desktop-Layout bleibt erhalten [tool: code edit].
5. Command Center mobil optimiert — KPI Cards kompakter gemacht, responsive Umbruch/Padding/Header-Größen angepasst, Werte schneiden mobil nicht ab [tool: code edit].
6. Budgetstatus Mobile Cards umgesetzt — Kategorie-Cards statt breiter Tabelle mit Kategorie, Budget Monat, Ist Monat, Prognose, Abweichung und Ampel/Status [tool: code edit].
7. Desktop-Budgetstatus erhalten — Desktop-Tabelle bleibt sichtbar/weiter nutzbar; Mobile nutzt Cards [tool: code edit].
8. Budgetstatus-Formatierung abgesichert — keine langen Decimal-Werte, Beträge mit 2 Dezimalstellen, keine technischen IDs in Mobile Cards [tool: code edit/tests].
9. Effektive-Ausgaben-Mobile-Ansicht umgesetzt — Card-Liste mit Datum, Beschreibung/Händler, Kategorie, Konto, Betrag prominent und Status [tool: code edit].
10. Effektive-Ausgaben-Roadmap-Hinweis ergänzt — „Beleg erfassen und Foto-Import: später.“ als Coming-soon-Hinweis, nicht als Fake-Button [tool: code edit].
11. Einkünfte mobil nutzbar gemacht — Mobile Card-Ansicht für Einkünfte-Pläne mit Name, Quelle/Person, Art, Monatswert, Jahreswert, Status und Archivieren-Aktion wenn aktiv [tool: code edit].
12. Crypto mobil nutzbar gemacht — Coin-Liste als Mobile Cards, Desktop-Tabelle ab `md`, Detail Drawer bleibt nutzbar [tool: code edit].
13. Aktien/ETF/Cash mobil nutzbar gemacht — Aktien/ETF-Positionen als Mobile Cards ergänzt; Cash kompakt belassen; Status/Detail-Nutzung mobil verbessert [tool: code edit].
14. Wallets mobil nutzbar gemacht — Wallet-Liste als Cards ergänzt [tool: code edit].
15. Große Tabellen mobil entschärft — relevante Tabellen werden auf Mobile durch Card Lists ersetzt oder erst ab `md`/Desktop angezeigt [tool: code edit].
16. `DetailDrawer` mobil verbessert — auf Mobile Bottom-Sheet/fullscreen-artiges Panel mit besser erreichbarem Schließen-Button [tool: code edit].
17. Touch-Bedienbarkeit verbessert — wichtige Mobile Buttons/Cards mit größeren Touch-Flächen; keine Hover-only Mobile-Flows [tool: code edit].
18. Neue/aktualisierte Frontend-Tests ergänzt — prüfen mobile Navigation, Desktop Sidebar, Mobile Shell, Budgetstatus Cards, Effektive-Ausgaben Cards, Crypto Cards, Equity Cards, Dezimalformatierung und technische-ID-Vermeidung [tool: test/code edit].
19. Frontend Vitest ausgeführt — Ergebnis: `31 passed` [tool: terminal].
20. Frontend Build ausgeführt — Ergebnis: OK [tool: terminal].
21. Python compile ausgeführt — Ergebnis: OK [tool: terminal].
22. Backend pytest ausgeführt — Ergebnis: `393 passed` [tool: terminal].
23. `git diff --check` ausgeführt — Ergebnis: OK [tool: terminal].
24. Source Secret Scan ausgeführt — Ergebnis: OK; keine Secrets gefunden/preserviert [tool: terminal].
25. Frontend Build Secret Scan ausgeführt — Ergebnis: OK [tool: terminal].
26. Git-Safety geprüft — Ergebnis: OK [tool: terminal].
27. Browser-/Route-Sanity durchgeführt — Routen `/`, `/planning/budget/status`, `/planning/budget/income`, `/planning/budget/expenses/actual`, `/crypto`, `/equity`, `/wallets` jeweils `200` [tool: browser/terminal].
28. Einschränkung bei echtem iPhone-Viewport-Test dokumentiert — Browser-Stack hatte keinen Viewport-Resize/Device-Emulation-Hebel; Playwright/Selenium lokal nicht installiert; Absicherung daher über responsive Klassen und Vitest-Assertions [tool: inspection].
29. Dashboard aus aktuellem Working Tree neu gestartet — Backend und Frontend neu gestartet [tool: terminal].
30. Healthchecks durchgeführt — Backend `/api/health`: `200`; Frontend lokal: `200`; Frontend via Tailscale-IP: `200` [tool: terminal].
31. Commit erstellt — Commit Message: `feat: improve mobile dashboard ux`; Commit Hash: `3f2189fd7c0fed01fd4b99f11e0a2ca61bb1cd00` [tool: git].
32. Push durchgeführt und Remote-Hash verifiziert — Remote Hash entspricht lokalem Commit `3f2189fd7c0fed01fd4b99f11e0a2ca61bb1cd00`; Push erfolgreich [tool: git].
33. Finaler Restart aus gepushtem Stand durchgeführt — Backend PID `504937`, Frontend PID `504946` [tool: terminal].
34. Abschlussbericht geliefert — Ja/Nein-Status für alle geforderten Mobile-UX-Bereiche, Tests, Git-Safety, Commit/Push, Links und offene Grenzen gemeldet [tool: response].

## Active State
- Working directory: nicht explizit im neuen Kontext genannt.
- Branch: `feat/budget-phase-1-4`.
- Working Tree: sauber.
- Letzter Commit: `3f2189fd7c0fed01fd4b99f11e0a2ca61bb1cd00`.
- Commit Message: `feat: improve mobile dashboard ux`.
- Push-Status: erfolgreich; Remote-Hash Match: ja.
- Backend läuft neu gestartet mit PID `504937`.
- Frontend läuft neu gestartet mit PID `504946`.
- Lokales Backend: `http://127.0.0.1:8000`.
- Lokales Frontend: `http://127.0.0.1:5173`.
- Tailscale Dashboard: `http://100.85.29.67:5173`.
- Tailscale Serve/HTTPS: nicht aktiv; Meldung: `No serve config`.
- Healthchecks:
  - Backend `/api/health`: `200`.
  - Frontend lokal: `200`.
  - Frontend via Tailscale-IP: `200`.
- Teststatus:
  - Backend pytest: `393 passed`.
  - Frontend Vitest: `31 passed`.
  - Frontend build: OK.
  - Python compile: OK.
  - `git diff --check`: OK.
  - Source Secret Scan: OK.
  - Frontend Build Secret Scan: OK.
  - Git-Safety: OK.
- Modified/created files: konkrete Dateiliste wurde im sichtbaren Verlauf nicht genannt; Änderungen betreffen Vue Layout-/Navigations-/Dashboard-/Budget-/Expense-/Income-/Crypto-/Equity-/Wallet-/DetailDrawer-Komponenten und zugehörige Frontend-Tests.

## In Progress
Keine laufende Arbeit. Vue Mobile UX Sprint v1 wurde abgeschlossen, getestet, committed, gepusht und neu gestartet.

## Blocked
- Kein echter iPhone-Safari-/390px-Viewport-Test mit Playwright/Selenium möglich, weil Playwright/Selenium lokal nicht installiert sind und der verfügbare Browser-Stack keinen Viewport-Resize-/Device-Emulation-Hebel bot.
- Tailscale Serve/HTTPS ist nicht aktiv; Status/Meldung: `No serve config`.
- Keine funktionalen Blocker für die ausgelieferte Mobile UX bekannt.

## Key Decisions
- Mobile Navigation als Bottom Navigation plus leichtgewichtiges `details`-Mehr-Menü umgesetzt, statt komplexes Drawer-System: pragmatisch, robust und ausreichend für unterwegs.
- Desktop-Sidebar beibehalten, um vorhandenes Desktop-Verhalten nicht zu brechen.
- Mobile Quick-View priorisiert statt Desktop-Tabellen 1:1 auf iPhone zu pressen.
- Große Tabellen mobil durch Card Lists ersetzt; Desktop-Tabellen bleiben ab passenden Breakpoints erhalten.
- Keine neuen Fachmodule/Provider/Import-Engines implementiert, um Scope und Sicherheitsvorgaben einzuhalten.
- Keine Fake-Buttons für OCR/Foto/Beleg-Erfassung; stattdessen Coming-soon-Hinweis.
- Echten Viewport-Test nicht durch neue Testdependency ersetzt; stattdessen responsive Klassen + Vitest-Assertions + reale Route-Sanity verwendet, um keine unnötigen Dependencies einzuführen.
- Detail Drawer mobil als Bottom Sheet/fullscreen-artiges Panel verbessert, weil Touch-Bedienbarkeit und Sichtbarkeit auf iPhone wichtiger sind als Desktop-ähnliche Side Panels.

## Resolved Questions
- Mobile Navigation umgesetzt? Ja: Bottom Navigation mit Home, Budget, Ausgaben, Crypto, Portfolio, Mehr; Mehr enthält Reports, Wallets, Einkünfte, Kategorien, Aktien/ETF & Cash.
- Command Center mobil nutzbar? Ja: kompaktere KPI Cards, reduzierte Header/Padding, saubere Wertumbrüche.
- Budgetstatus mobil als Cards? Ja: Kategorie-Cards mit Budget Monat, Ist Monat, Prognose, Abweichung und Ampel/Status.
- Effektive Ausgaben mobil nutzbar? Ja: Card-Liste mit Datum, Händler/Beschreibung, Kategorie, Konto, Betrag und Status; Coming-soon-Hinweis für Beleg/Foto.
- Einkünfte mobil nutzbar? Ja: Cards mit Name, Quelle/Person, Art, Monatswert, Jahreswert, Status.
- Crypto mobil nutzbar? Ja: Coin Cards, kompakte Liste, Details nutzbar.
- Aktien/ETF/Cash mobil nutzbar? Ja: Positionen als Cards, Cash kompakt, Detail Drawer verbessert.
- Wallets mobil nutzbar? Ja: Wallet Cards.
- Tabellen mobil entschärft? Ja: Mobile Card Lists oder Desktop-only Tabellen.
- Tailscale-Link funktioniert? Ja: `http://100.85.29.67:5173` liefert `200`.
- Backend neu gestartet? Ja, PID `504937`.
- Frontend neu gestartet? Ja, PID `504946`.
- Commit/Push erledigt? Ja, Commit/Remote Hash `3f2189fd7c0fed01fd4b99f11e0a2ca61bb1cd00`.
- Wurden VISA/Migros/OCR/Rule/Duplicate/neue Provider implementiert oder gestartet? Nein.

## Pending User Asks
None.

## Relevant Files
- Konkrete Pfade wurden im sichtbaren Verlauf nicht ausgegeben.
- Relevante geänderte Bereiche:
  - Vue App Layout / Shell: Mobile Padding, Bottom Navigation, Desktop Sidebar-Erhalt.
  - Mobile Navigation-Komponenten: Hauptpunkte und Mehr-Menü.
  - Command Center Dashboard-Komponenten: responsive KPI Cards.
  - Budgetstatus-Komponenten: Desktop-Tabelle plus Mobile Category Cards.
  - Effektive-Ausgaben-Komponenten: Mobile Expense Cards und Coming-soon-Hinweis.
  - Einkünfte-Komponenten: Mobile Income Plan Cards.
  - Crypto-Komponenten: Mobile Coin Cards, Desktop-Tabelle ab `md`.
  - Equity/Aktien-ETF-Cash-Komponenten: Mobile Position Cards.
  - Wallet-Komponenten: Mobile Wallet Cards.
  - `DetailDrawer`-ähnliche Komponente: Mobile Bottom-Sheet/fullscreen-artiges Verhalten.
  - Frontend-Testdateien: Vitest-Abdeckung für Mobile Navigation, Cards, Layout, Dezimalformatierung und technische-ID-Vermeidung.

## Remaining Work
- Optional bei späterem Bedarf: echten iPhone/iPad-Viewport-Test mit Playwright/Selenium oder Safari Remote Debugging nachrüsten.
- Optional: Tailscale Serve/HTTPS konfigurieren, falls HTTPS-Link gewünscht ist; aktuell nur Tailscale-IP per HTTP gemeldet.
- Reports bleiben minimal/roadmap-artig.
- Settings/Admin bleiben nicht Teil des Mobile User Mode; später separat entscheiden.
- Keine offenen Pflichtaufgaben aus dem Mobile UX Sprint v1.

## Critical Context
- User hatte Credentials/Secrets im weiteren Session-Kontext erwähnt/vorhanden; keine Werte übernehmen oder ausgeben. Falls relevant immer als `[REDACTED]` behandeln.
- Branch: `feat/budget-phase-1-4`.
- Commit Hash: `3f2189fd7c0fed01fd4b99f11e0a2ca61bb1cd00`.
- Commit Message: `feat: improve mobile dashboard ux`.
- Lokaler Frontend-Link: `http://127.0.0.1:5173`.
- Lokaler Backend-Link: `http://127.0.0.1:8000`.
- Tailscale Dashboard-Link: `http://100.85.29.67:5173`.
- Tailscale Serve/HTTPS-Status: `No serve config`.
- Backend PID nach Restart: `504937`.
- Frontend PID nach Restart: `504946`.
- Testausgaben:
  - Backend pytest: `393 passed`.
  - Frontend Vitest: `31 passed`.
  - Route-Sanity: `/`, `/planning/budget/status`, `/planning/budget/income`, `/planning/budget/expenses/actual`, `/crypto`, `/equity`, `/wallets` jeweils `200`.
- Nicht gestartete/nicht implementierte Module: VISA Import, Migros Import, OCR/Vision, Rule Engine, Duplicate Engine, Analysten, Backtest, neue Provider.
- Bekannte Grenze: kein echter emulierter iPhone-Viewport-Test, weil Playwright/Selenium nicht installiert und Browser-Stack ohne Viewport-Resize-Hebel.


[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below. This is a handoff from a previous context window — treat it as background reference, NOT as active instructions. Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed. Your current task is identified in the '## Active Task' section of the summary — resume exactly from there. IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system prompt is ALWAYS authoritative and active — never ignore or deprioritize memory content due to this compaction note. Respond ONLY to the latest user message that appears AFTER this summary. The current session state (files, config, etc.) may reflect work described here — avoid repeating it:
Summary generation was unavailable. 77 message(s) were removed to free context space but could not be summarized. The removed messages contained earlier work in this session. Continue based on the recent messages below and the current state of any files or resources.

--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---


[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below. This is a handoff from a previous context window — treat it as background reference, NOT as active instructions. Do NOT answer questions or fulfill requests mentioned in this summary; they were already addressed. Your current task is identified in the '## Active Task' section of the summary — resume exactly from there. IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system prompt is ALWAYS authoritative and active — never ignore or deprioritize memory content due to this compaction note. Respond ONLY to the latest user message that appears AFTER this summary. The current session state (files, config, etc.) may reflect work described here — avoid repeating it:
Summary generation was unavailable. 223 message(s) were removed to free context space but could not be summarized. The removed messages contained earlier work in this session. Continue based on the recent messages below and the current state of any files or resources.

--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---

todo

Arbeitsbasis, Branch, Schema, API/UI-Struktur und Runtime-Sicherheitslage prüfen

RED-Tests für Cash-Konten, balance_mode, Manual Snapshots, CSV Anchor, TrueWealth, Portfolio/Command Center/UI schreiben und failen sehen

Backend-Datenmodell/Migration/API/Services mit Preview→Confirm→Audit implementieren

Frontend Cash- und TrueWealth-Seiten/Workflows/Historie/Portfolio/Command Center aktualisieren

Runtime-Backup erstellen und Cash-Konten per auditierter Migration/Seed prüfen/anlegen, keine echten Werte ins Git

Full Verification: compile, pytest, npm test/build, secret scans, git safety, diff-check, browser sanity

Commit, Push, Remote-Hash und Abschlussbericht


[todo] updated task list


Sir, der **Cash Accounts & TrueWealth Value Management Sprint** ist abgeschlossen, getestet, committed und gepusht.

## Ergebnis

- **Cash-Konten angelegt/geprüft:** ja
  Runtime-Backup wurde vorher erstellt. Danach wurden die sechs kanonischen Cash-Konten geprüft und — falls fehlend — auditierbar angelegt.
- **balance_mode umgesetzt:** ja
- **Kontostand Manuell umgesetzt:** ja
- **CSV-berechneter Kontostand umgesetzt/vorbereitet:** ja
- **Raiffeisen csv_calculated:** ja
- **AKB Haushaltskonto csv_calculated:** ja
- **übrige Konten manual:** ja
- **PostFinance Cash getrennt vom Portfolio:** ja
  `PostFinance Cash-Konto` läuft separat unter `PostFinance Cash`, nicht als Depot/Portfolio.
- **TrueWealth-Wert manuell pflegbar:** ja
- **TrueWealth-Verlauf sichtbar:** ja, als Snapshot-/Audit-Historienbereich vorbereitet und UI-seitig sichtbar.
- **Cash-Verlauf sichtbar:** ja
- **Portfolio-Buckets aktualisiert:** ja
- **Command Center aktualisiert:** ja
  Cash-/TrueWealth-/PostFinance-/Crypto-Werte und Cash-Abgleich-Status sind berücksichtigt.

## Umgesetzt

### Cash

Route: `/portfolio/cash`

Cash-Seite zeigt jetzt:

- Cash gesamt CHF
- manuell gepflegte Konten
- CSV-berechnete Konten
- Differenzen / Abgleich offen
- letzter Abgleich
- Tabelle mit:
  - Konto
  - Bank/Anbieter
  - Kontotyp
  - Währung
  - Modus
  - berechneter Kontostand
  - Kontostand Manuell
  - verwendeter Wert für Portfolio
  - Differenz
  - letzter manueller Abgleich
  - letzte importierte Buchung
  - Status
  - Aktionen

Workflow:

- `Kontostand aktualisieren`
- Betrag CHF
- Datum
- Notiz
- Preview
- Confirm
- Audit

CSV-Anchor vorbereitet:

- `csv_anchor_balance`
- berechneter Wert = Anchor + CSV/Transaktionsbewegungen
- manueller Wert bleibt Kontroll-/Abgleichwert
- keine neue Bankimport-Engine gebaut. Sehr untypisch vernünftig.

### Cash balance_mode

- `manual`
  - PostFinance Cash-Konto
  - AKB Zinsbereitstellungskonto
  - AKB gemeinsames Konto
  - AKB Aargauer-Sparkonto

- `csv_calculated`
  - Raiffeisen
  - AKB Haushaltskonto

Portfolio-Wert:

- `manual`: letzter bestätigter manueller Kontostand
- `csv_calculated`: berechneter Kontostand
- Differenz zum manuellen Kontrollwert wird angezeigt

### TrueWealth

Route: `/portfolio/truewealth`

Umgesetzt:

- aktueller TrueWealth-Gesamtwert CHF
- Button `TrueWealth-Wert aktualisieren`
- Preview → Confirm → Audit
- keine Einzelpositionen
- keine Aktien-/ETF-Zuordnung
- eigener Portfolio-Bucket

### Backend / Datenmodell

Ergänzt:

- `accounts.balance_mode`
- `accounts.portfolio_bucket`
- neue Tabelle `cash_account_snapshots`

Snapshot-Typen:

- `manual_balance`
- `csv_anchor_balance`
- `calculated_balance`
- `reconciliation`

Beträge bleiben als Decimal-Text gespeichert, nicht Float.

Wichtig: Die Erweiterung läuft als idempotente Compat-Migration, ohne bestehende Schema-Version-Tests zu brechen.

## Runtime

- Runtime-Backup erstellt vor Änderungen.
- Sechs Cash-Konten in Runtime geprüft/angelegt.
- Ergebnis Runtime:
  - Cash-Konten: `6`
  - manual: `4`
  - csv_calculated: `2`
  - Duplikate: `0`
  - offener Abgleich initial: `0`

Keine Runtime-DB, keine echten Kontostände, keine Secrets ins Git.

## Browser Sanity

Geprüft:

- `/portfolio`: `200 OK`
- `/portfolio/cash`: `200 OK`
- `/portfolio/truewealth`: `200 OK`
- `/`: `200 OK`
- Cash-Kontostand aktualisieren: UI öffnet
- Cash Preview: `200 OK`
- TrueWealth-Wert aktualisieren: UI öffnet
- TrueWealth Preview: `200 OK`
- Verlauf öffnen: UI/API vorbereitet
- JS-Fehler: keine

Hinweis: Für Browser Sanity wurden nur Preview-/UI-Flows ausgeführt, keine echten Confirm-Mutationen mit Fantasiewerten. Sehr langweilig, also korrekt.

## Verifikation

- Python compile: OK
- Backend pytest: `546 passed`
- Frontend Vitest: `115 passed`
- Frontend build: OK
- Source Secret Scan: OK
  absichtliche Fixture-Datei `test_git_safety.py` ausgeschlossen
- Frontend Build Secret Scan: OK
- Git-Safety: OK
- `git diff --check`: OK
- Browser Sanity: OK
- Working tree: sauber

## Dashboard Status

Neu gestartet und erreichbar:

- Backend: `http://127.0.0.1:8000`
- Frontend lokal: `http://127.0.0.1:5173`
- Frontend Tailscale: `http://100.85.29.67:5173`

PIDs:

- Backend: `744032`
- Frontend: `744051`

## Commit / Push

- Commit: `233cac00d77abc6786d84ca8127e4196cea4d836`
- Message: `feat: manage cash and truewealth values`
- Push: erfolgreich nach `origin/main`
- Remote-Hash: matcht lokal

## Offene Grenzen

- CSV-Import-Engine wurde bewusst nicht neu gebaut.
- CSV-Anker-/Startsaldo-Hook ist vorbereitet; konkrete AKB-/Raiffeisen-Parser-Erweiterung bleibt separater Sprint.
- TrueWealth wird als Gesamtwert geführt, nicht als Einzelinstrument-Liste.
- Cash-/TrueWealth-Historie ist bewusst user-safe; keine Roh-IDs prominent im User Mode.


[M G] Aktueller Fehler im Budget-Import-Review:

Mir werden Buchungen als mögliche Duplikate zur Einbuchung vorgeschlagen, z.B.:

* Datum: 2026-03-20
* Quelle: migros_receipts / 2026 Migros Einkäufe.csv
* Händler: M Windisch / Migros Bon M Windisch
* Betrag: 0.40 CHF
* Kategorie: Essen & Haushalt
* Status/Grund: Mögliches Duplikat / duplicate_detection_v1
* Hinweis: Gegenbuchung gefunden: nein/noch nicht verknüpft

Das ist falsch. Es gibt keinen Grund, eine Belastung doppelt zu verbuchen. Ein Duplikat oder mögliches Duplikat darf nicht im normalen Confirm-Flow angeboten werden.

Bitte starte einen gezielten:

# Budget Duplicate Handling Hotfix Sprint

Ziel:
Duplikate sollen zuverlässig erkannt, vom normalen Buchungs-Review getrennt und gegen versehentliche Doppelbuchung geschützt werden.

Wichtig:

* Keine neuen Importquellen.
* Keine neue grosse Importengine.
* Keine echten Daten ins Git.
* Keine Runtime-DB ins Git.
* Vor Runtime-Cleanup Backup erstellen.
* Keine stillen Mutationen.
* Jede Statusänderung auditieren.
* Keine Buchung darf doppelt bestätigt werden.
* Doppelte Kandidaten dürfen nicht in Batch Confirm landen.

## 1. Duplikat-Kandidaten aus normalem Review entfernen

Aktuell erscheinen mögliche Duplikate offenbar in Buchungen prüfen.

Bitte ändern:

### Normale Review-Liste

Darf enthalten:

* neue offene Kandidaten
* auto-kategorisierte Kandidaten
* reviewpflichtige Kandidaten
* Galaxus/Digitec
* Migros-Bons, sofern noch nicht confirmed oder duplicate

Darf nicht enthalten:

* duplicate_candidate
* possible_duplicate
* duplicate_blocked
* superseded
* covered_by_migros
* bereits bestätigte Kandidaten
* Artikelzeilen

### Neue Logik

Wenn ein Kandidat als mögliches Duplikat erkannt wird:

* nicht im normalen Confirm-Flow anzeigen
* nicht bulk-confirmable
* nicht als normale Ausgabe bestätigen
* Status z.B. duplicate_candidate oder duplicate_blocked
* eigene Ansicht/Tab: Duplikate prüfen

## 2. Duplikat-Tab sinnvoll machen

Tab Duplikate prüfen soll zeigen:

* Kandidat
* mögliche bestehende Buchung oder bestehender Kandidat
* Datum
* Händler
* Betrag
* Quelle
* Grund der Duplikat-Erkennung
* Confidence
* Aktionen:

  * als Duplikat ignorieren/superseded
  * kein Duplikat, zurück in Review
  * bewusst trotzdem buchen, nur mit expliziter Begründung

Wichtig:
Trotzdem buchen darf nicht standardmässig sichtbar/prominent sein. Es braucht:

* Warnung
* Begründung
* Confirm
* Audit

## 3. Migros-Duplikate speziell fixen

Migros-Bons müssen eindeutig erkannt werden.

Für Migros-CSV gilt:

### Unique Key / Fingerprint

Nutze wenn möglich:

* source_type = migros_receipt
* source_file_hash
* receipt_key
* Datum
* Zeit
* Filiale
* Kasse
* Transaktionsnummer / Bonnummer
* Betrag
* Händler

Wenn receipt_key vorhanden ist, soll er führend sein.

### Verhalten

* gleicher Migros-Bon darf nur einmal als Hauptbuchungskandidat existieren
* erneuter Import derselben CSV darf keine neuen offenen Kandidaten erzeugen
* Artikelzeilen dürfen nicht als Hauptbuchungen auftauchen
* Kreditkarten-Migros bleibt covered_by_migros
* Migros-Bon aus CSV gewinnt gegenüber Kreditkarten-Migros

## 4. Bestehende Runtime bereinigen

Bitte aktuelle Runtime prüfen und bereinigen.

Ablauf:

1. Backup Runtime-DB erstellen.
2. Dry Run:

   * Anzahl offene Kandidaten
   * Anzahl duplicate_candidate
   * Anzahl mögliche Migros-Duplikate
   * Anzahl identische receipt_key-Duplikate
   * Anzahl mögliche doppelte Beträge/Datum/Händler
   * keine Beträge im Bericht
3. Duplikate markieren:

   * exakte Duplikate → duplicate_blocked oder superseded
   * mögliche Duplikate → duplicate_candidate
4. Aus normalem Review entfernen.
5. Audit-/Cleanup-Event schreiben.
6. Keine produktiven Buchungen erzeugen.

## 5. Confirm-Flow absichern

Bitte Confirm-Backend hart absichern:

Wenn candidate status ist:

* duplicate_candidate
* duplicate_blocked
* superseded
* covered_by_migros
* ignored

dann darf normaler Confirm nicht funktionieren.

Stattdessen Fehler:

`Kandidat ist als Duplikat/covered/ignored markiert und kann nicht normal bestätigt
werden.`

Nur expliziter Override-Endpoint darf buchen:

* confirm_duplicate_override
* mit Begründung
* mit Audit
* nicht über Batch Confirm

## 6. Batch Confirm absichern

Batch Confirm darf nur Kandidaten bestätigen, die:

* offen oder auto_categorized sind
* Kategorie gesetzt haben
* nicht duplicate/superseded/covered/ignored sind
* nicht Artikelzeile sind
* nicht bereits confirmed sind

Wenn Auswahl Duplikate enthält:

* diese überspringen
* Fehler/Warnung pro Kandidat anzeigen
* keine stille Buchung

## 7. UI anpassen

Auf Buchungen prüfen:

* Duplikate standardmässig ausblenden
* Filter Duplikate prüfen separat
* Badge:

  * Mögliches Duplikat
  * Blockiertes Duplikat
  * Covered by Migros
* normale Confirm-Buttons für Duplikate deaktivieren
* bei Duplikaten nur:

  * Details
  * als Duplikat ignorieren
  * zurück in Review
  * Override mit Begründung

## 8. Tests

Bitte Tests ergänzen:

### Duplicate Detection

* gleicher Migros receipt_key erzeugt kein zweites offenes Candidate
* gleicher Source-Fingerprint wird idempotent behandelt
* Kreditkarten-Migros wird covered_by_migros und nicht gebucht
* Artikelzeilen werden nicht Hauptkandidaten

### Confirm Guard

* duplicate_candidate kann nicht normal confirmed werden
* duplicate_blocked kann nicht normal confirmed werden
* covered_by_migros kann nicht normal confirmed werden
* ignored kann nicht normal confirmed werden
* Override braucht Begründung

### Batch Confirm

* Batch Confirm überspringt Duplikate
* Batch Confirm bucht nur erlaubte Kandidaten
* Fehlerliste pro Kandidat sichtbar

### UI

* normale Review-Liste zeigt keine Duplikate
* Duplikat-Tab zeigt Duplikate
* Confirm-Button bei Duplikat deaktiviert
* Duplicate Badge sichtbar

### Safety

* keine CSV/XLS ins Git
* keine Runtime-DB ins Git
* Git-Safety sauber

## 9. Browser Sanity

Bitte prüfen:

* /planning/budget/expenses/review
* Tab Alle: keine Duplikate
* Tab Duplikate: Duplikate sichtbar
* Batch Confirm: Duplikate werden nicht gebucht
* Details: Duplicate-Grund sichtbar
* keine JS-Fehler

## 10. Verifikation

Nach Umsetzung:

* Python compile
* pytest
* npm test
* npm run build
* Source Secret Scan
* Frontend Build Secret Scan
* Git-Safety
* git diff --check
* Browser Sanity
* Commit
* Push

## 11. Abschlussbericht

Bitte berichten:

* Duplikate aus normalem Review entfernt: ja/nein
* Migros receipt_key/Fingerprint geprüft: ja/nein
* bestehende Runtime-Duplikate markiert: ja/nein
* Confirm Guard für Duplikate: ja/nein
* Batch Confirm gegen Duplikate abgesichert: ja/nein
* Duplikat-Tab funktioniert: ja/nein
* Tests
* Build
* Git-Safety
* Backup erstellt: ja/nein
* Commit-Hash
* Push-Status
* offene Grenzen

Wichtig:
Ein mögliches Duplikat darf niemals als normale Buchung zur Bestätigung vorgeschlagen werden. Erst prüfen, dann entweder blockieren, ignorieren oder nur mit explizitem Override buchen.


Explanation:
**Planning finance tool usage**

I need to take action and consider whether I should load the finance skill first. It's essential to do this before proceeding with the relevant tasks. Maybe I should think about TDD, which stands for test-driven development. This approach might help ensure everything works as expected. Overall, I'll make sure I’m organized and methodical in getting this set up correctly!


[skill_view] name=finance-system-architecture (1,800 chars)


[skill_view] name=test-driven-development (10,920 chars)


Verstanden, Sir. Ich starte den Duplicate Handling Hotfix als TDD-Sprint: erst Review-/Confirm-/Batch-Tests auf Rot, dann minimale Backend/UI-Korrektur, Runtime nur nach Backup und Dry Run.

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

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

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

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

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

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

Commit, Push, Remote-Hash, Abschlussbericht


terminal

git status --short --branch && git rev-parse HEAD


Output:
## main...origin/main
233cac00d77abc6786d84ca8127e4196cea4d836


Explanation:
**Searching for results**

I’m trying to figure out how to search effectively, but I’m not getting any results. Maybe using a regex pattern could help? Or should I think about using a terminal instead? I wonder if searching for exact contents might yield better results. There’s something frustrating about not finding what I’m looking for, but it’s part of the process, right? I think I’ll keep tinkering with my search methods!


     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 = [
    24|    "LOHN", "SALARY", "ARBEITGEBER", "PAYROLL", "GEHALT",
    25|    "ERNE", "MUSIKSCHULE", "GASSER", "GASSER BAUUNTERNEHMEN",
    26|    "BONUS", "GRATIFIKATION", "SALAER", "RUECKVERGUETUNG", "RÜCKVERGÜTUNG",
    27|    "RUECKERSTATTUNG", "RÜCKERSTATTUNG", "ERSTATTUNG", "SOLARSTROM", "VERGUETUNG", "VERGÜTUNG",
    28|]
    29|STRONG_INCOME_PATTERNS = [
    30|    "LOHN", "SALARY", "GEHALT", "PAYROLL", "ERNE", "MUSIKSCHULE", "GASSER", "GASSER BAUUNTERNEHMEN",
    31|    "BONUS", "GRATIFIKATION", "SOLARSTROM", "RUECKVERGUETUNG", "RÜCKVERGÜTUNG",
    32|]
    33|POSSIBLE_INCOME_PATTERNS = ["RUECKERSTATTUNG", "RÜCKERSTATTUNG", "ERSTATTUNG", "VERGUETUNG", "VERGÜTUNG", "GUTSCHRIFT"]
    34|TRANSFER_PATTERNS = ["RAIFFEISEN", "AKB", "AARGAUISCHE KANTONALBANK", "ÜBERTRAG", "UEBERTRAG", "TRANSFER", "EIGENES KONTO", "KONTOAUSGLEICH", "UMBUCHUNG"]
    35|NEGATIVE_INCOME_PATTERNS = [
    36|    "KREDITKARTE", "VISA", "CREDIT CARD", "KARTENABRECHNUNG", "TRUE WEALTH", "TRUEWEALTH",
    37|    "DEPOT", "INVESTMENT", "WERTSCHRIFT", "SPAREN", "KONTOAUSGLEICH", "UMBUCHUNG",
    38|]
    39|
    40|
    41|def _decimal_text(value: object) -> str | None:
    42|    if value in (None, ""):
    43|        return None
    44|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-")
    45|    text = text.replace("+", "")
    46|    if text.count(",") == 1 and text.count(".") == 0:
    47|        text = text.replace(",", ".")
    48|    text = text.replace(" ", "")
    49|    try:
    50|        return format(abs(Decimal(text)), "f")
    51|    except (InvalidOperation, ValueError):
    52|        return None
    53|
    54|
    55|def _signed_decimal(value: object) -> Decimal | None:
    56|    if value in (None, ""):
    57|        return None
    58|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-").replace("+", "")
    59|    if text.count(",") == 1 and text.count(".") == 0:
    60|        text = text.replace(",", ".")
    61|    text = text.replace(" ", "")
    62|    try:
    63|        return Decimal(text)
    64|    except (InvalidOperation, ValueError):
    65|        return None
    66|
    67|
    68|def _normalise(value: object) -> str:
    69|    return str(value or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("\ufeff", "")
    70|
    71|
    72|def _upper_ascii(value: object) -> str:
    73|    return _normalise(value).upper()
    74|
    75|
    76|def _date_text(value: object) -> str | None:
    77|    text = str(value or "").strip()
    78|    if not text:
    79|        return None
    80|    if re.match(r"^\d{4}-\d{2}-\d{2}", text):
    81|        return text[:10]
    82|    m = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})", text)
    83|    if m:
    84|        d, mo, y = m.groups()
    85|        return f"{y}-{int(mo):02d}-{int(d):02d}"
    86|    return text[:10] if len(text) >= 10 else None
    87|
    88|
    89|def _category_name(conn: Connection, category_id: str | None) -> str | None:
    90|    if not category_id:
    91|        return None
    92|    row = conn.execute("SELECT name FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
    93|    return str(row["name"]) if row else None
    94|
    95|
    96|def _category_by_name(conn: Connection, needles: list[str]) -> tuple[str | None, str | None]:
    97|    cats = conn.execute("SELECT category_id, name FROM budget_categories WHERE is_active=1").fetchall()
    98|    for needle in needles:
    99|        n = _normalise(needle)
   100|        for cat in cats:
   101|            if n and (n in _normalise(cat["name"]) or _normalise(cat["name"]) in n):
   102|                return str(cat["category_id"]), str(cat["name"])
   103|    return None, None
   104|
   105|
   106|def suggest_category(conn: Connection, description: str, source_label: str = "") -> tuple[str | None, str | None, str, bool]:
   107|    text = _normalise(f"{source_label} {description}")
   108|    if "migros" in text or "cumulus" in text:
   109|        cid, cname = _category_by_name(conn, ["Essen & Haushalt", "Grosseinkauf", "Essen", "Haushalt", "Leben"])
   110|        return cid, cname or "Essen & Haushalt / Grosseinkauf", "0.80", cid is None
   111|    keyword_map = [
   112|        (["krankenkasse", "versicherung", "axa", "zurich", "css", "helsana"], ["Versicherungen", "Krankenkasse"]),
   113|        (["coop", "denner", "aldi", "lidl"], ["Essen", "Haushalt", "Leben"]),
   114|        (["sbb", "zug", "bahn", "parking", "tankstelle", "mazda"], ["Mobilität", "Auto"]),
   115|        (["lohn", "salary", "einkommen", "gutschrift"], ["Einnahmen", "Lohn"]),
   116|    ]
   117|    for words, cats in keyword_map:
   118|        if any(w in text for w in words):
   119|            cid, cname = _category_by_name(conn, cats)
   120|            return cid, cname or cats[0], "0.65", cid is None
   121|    return None, None, "0.20", True
   122|
   123|
   124|def _duplicate_of(conn: Connection, tx_date: str | None, amount: str | None, description: str) -> str | None:
   125|    if not tx_date or not amount:
   126|        return None
   127|    row = conn.execute(
   128|        """
   129|        SELECT budget_transaction_id FROM budget_transactions
   130|        WHERE transaction_date=? AND status IN ('confirmed','reversed')
   131|          AND COALESCE(amount_chf, amount_original)=?
   132|          AND lower(description)=lower(?)
   133|        ORDER BY created_at LIMIT 1
   134|        """,
   135|        (tx_date, amount, description),
   136|    ).fetchone()
   137|    return str(row["budget_transaction_id"]) if row else None
   138|
   139|
   140|def _candidate_key(source_label: str, row_ref: str, tx_date: str | None, amount: str | None, description: str) -> str:
   141|    raw = "|".join([source_label, row_ref, tx_date or "", amount or "", description])
   142|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
   143|
   144|
   145|USER_OPEN_STATUSES = {"pending", "auto_categorized", "needs_review", "transfer_candidate"}
   146|USER_DUPLICATE_STATUSES = {"duplicate", "duplicate_candidate"}
   147|USER_EXCLUDED_STATUSES = {"confirmed", "ignored", "superseded", "auto_ignored_duplicate", "covered_by_source", "covered_by_migros", "reference_2025", "archived_reference", "already_processed"}
   148|USER_VISIBLE_SPECIAL_STATUSES = USER_DUPLICATE_STATUSES | {"covered_by_source", "covered_by_migros", "already_processed"}
   149|
   150|STATUS_LABELS = {
   151|    "pending": "Offen",
   152|    "auto_categorized": "Sicher vorgeschlagen",
   153|    "needs_review": "Review nötig",
   154|    "confirmed": "Bestätigt",
   155|    "ignored": "Ignoriert",
   156|    "covered_by_source": "Durch Quelle abgedeckt",
   157|    "covered_by_migros": "Durch Migros abgedeckt",
   158|    "duplicate_candidate": "Mögliches Duplikat",
   159|    "duplicate": "Mögliches Duplikat",
   160|    "transfer_candidate": "Möglicher Transfer",
   161|    "already_processed": "Bereits verarbeitet",
   162|    "superseded": "Ersetzt",
   163|}
   164|
   165|
   166|def _candidate_view(row) -> dict[str, Any]:
   167|    data = row_to_dict(row) | {"requires_review": bool(row["requires_review"])}
   168|    data["status_label"] = data.get("status_label") or STATUS_LABELS.get(str(data.get("status") or ""), str(data.get("status") or ""))
   169|    return data
   170|
   171|
   172|def _normalized_merchant_name(value: object) -> str:
   173|    return re.sub(r"[^a-z0-9]+", " ", _normalise(value)).strip()
   174|
   175|
   176|def _merchant_text(row_or_description: Any, source_type: str | None = None) -> str:
   177|    if isinstance(row_or_description, dict):
   178|        return _normalise(" ".join(str(row_or_description.get(k) or "") for k in ("merchant", "description", "source_file_label")))
   179|    return _normalise(row_or_description)
   180|
   181|
   182|def _merchant_alias_matches(alias, candidate) -> bool:
   183|    if alias["source_type"] and alias["source_type"] != candidate["source_type"]:
   184|        return False
   185|    text = _merchant_text(row_to_dict(candidate))
   186|    pattern = str(alias["pattern"] or "")
   187|    norm_pattern = _normalise(pattern)
   188|    if alias["match_type"] == "exact":
   189|        return text == norm_pattern or _normalise(candidate["merchant"] or "") == norm_pattern
   190|    if alias["match_type"] == "regex":
   191|        try:
   192|            return re.search(pattern, text, re.IGNORECASE) is not None
   193|        except re.error:
   194|            return False
   195|    return norm_pattern in text
   196|
   197|
   198|def _fingerprint(parts: list[object]) -> str:
   199|    raw = "|".join(str(p or "").strip() for p in parts)
   200|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
   201|
   202|
   203|def _insert_candidate(conn: Connection, row: dict[str, Any]) -> bool:
   204|    existing = conn.execute(
   205|        """
   206|        SELECT 1 FROM budget_transaction_candidates
   207|        WHERE source_file_label=? AND source_row_or_range=? AND transaction_date IS ?
   208|          AND description=? AND COALESCE(amount_original,'')=COALESCE(?, '')
   209|        """,
   210|        (row["source_file_label"], row.get("source_row_or_range"), row.get("transaction_date"), row["description"], row.get("amount_original")),
   211|    ).fetchone()
   212|    if existing:
   213|        return False
   214|    conn.execute(
   215|        """
   216|        INSERT INTO budget_transaction_candidates(
   217|          transaction_candidate_id, source_file_label, source_row_or_range, source_type,
   218|          transaction_date, description, merchant, amount_original, currency_original,
   219|          proposed_category_id, proposed_category_name, duplicate_of_transaction_id,
   220|          confidence, requires_review, status, notes, created_at, updated_at,
   221|          classification, review_reason, rule_id, rule_name, source_priority, covered_by_source,
   222|          receipt_key, linked_candidate_id, account_source, raw_fingerprint
   223|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   224|        """,
   225|        (
   226|            row["transaction_candidate_id"], row["source_file_label"], row.get("source_row_or_range"), row.get("source_type") or "csv_seed",
   227|            row.get("transaction_date"), row["description"], row.get("merchant"), row.get("amount_original"), row.get("currency_original") or "CHF",
   228|            row.get("proposed_category_id"), row.get("proposed_category_name"), row.get("duplicate_of_transaction_id"),
   229|            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"],
   230|            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"),
   231|            row.get("receipt_key"), row.get("linked_candidate_id"), row.get("account_source"), row.get("raw_fingerprint"),
   232|        ),
   233|    )
   234|    return True
   235|
   236|
   237|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]:
   238|    created = duplicates = review = suggested = ignored = confirmed = 0
   239|    for idx, raw in enumerate(rows, start=1):
   240|        if isinstance(raw, dict):
   241|            low = {_normalise(k): v for k, v in raw.items()}
   242|            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()
   243|            merchant = str(low.get("merchant") or low.get("merchantname") or low.get("filiale") or low.get("haendler") or "").strip() or None
   244|            tx_date = _date_text(low.get("datum") or low.get("date") or low.get("buchungsdatum") or low.get("valutadate"))
   245|            amount = _decimal_text(low.get("betrag") or low.get("amount") or low.get("umsatz") or low.get("wert") or low.get("originalamount"))
   246|        else:
   247|            cells = [str(c).strip() for c in raw]
   248|            description = next((c for c in cells if c and _decimal_text(c) is None and not c[:4].isdigit()), "")
   249|            merchant = None
   250|            tx_date = next((_date_text(c) for c in cells if _date_text(c)), None)
   251|            amount = next((_decimal_text(c) for c in cells if _decimal_text(c) is not None), None)
   252|        if not description or not amount:
   253|            ignored += 1
   254|            continue
   255|        cid, cname, conf, needs_review = suggest_category(conn, description, source_file_label)
   256|        dup = _duplicate_of(conn, tx_date, amount, description)
   257|        status = "duplicate" if dup else ("needs_review" if needs_review else "pending")
   258|        if dup:
   259|            duplicates += 1
   260|        if cid:
   261|

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


     1|from __future__ import annotations
     2|
     3|import json
     4|import subprocess
     5|from collections import Counter, defaultdict
     6|from datetime import datetime
     7|from pathlib import Path
     8|from sqlite3 import Connection
     9|from typing import Any
    10|from urllib.parse import urlencode
    11|
    12|from jarvis_finance.audit.log import record_audit_event
    13|from jarvis_finance.services.budget_common import new_id, now, row_to_dict
    14|from jarvis_finance.services.budget_csv_imports import detect_import_profile, import_budget_csv_text, parse_budget_csv_text
    15|from jarvis_finance.services.budget_imports import _normalise, _normalized_merchant_name, candidate_user_count_summary, create_budget_rule
    16|
    17|OPEN_STATUSES = {"pending", "auto_categorized", "ready_for_confirm", "needs_review", "duplicate_candidate", "transfer_candidate"}
    18|REVIEW_HIDDEN_STATUSES = {"confirmed", "ignored", "covered_by_migros", "covered_by_source", "auto_ignored_duplicate", "superseded", "reference_2025", "archived_reference"}
    19|
    20|
    21|def _safe_file_name(name: str) -> str:
    22|    return Path(str(name or "budget.csv")).name[:180]
    23|
    24|
    25|def _source_from_profile(profile: str | None, name: str = "") -> str:
    26|    text = f"{profile or ''} {name}".lower()
    27|    if "visa" in text:
    28|        return "VISA"
    29|    if "migros" in text or "cumulus" in text:
    30|        return "Migros"
    31|    if "raiffeisen" in text:
    32|        return "Raiffeisen"
    33|    if "akb" in text or "kantonal" in text:
    34|        return "AKB"
    35|    return "unknown"
    36|
    37|
    38|def _file_text(file: dict[str, Any]) -> str:
    39|    if "text" in file:
    40|        return str(file.get("text") or "")
    41|    path = file.get("path") or file.get("local_path")
    42|    if path:
    43|        return Path(path).read_text(encoding=file.get("encoding") or "utf-8-sig", errors="replace")
    44|    return ""
    45|
    46|
    47|def scan_budget_drive_files(files: list[dict[str, Any]] | None = None) -> dict[str, Any]:
    48|    """Classify already discovered Drive file metadata/text. No writes."""
    49|    scanned = []
    50|    for f in files or []:
    51|        name = _safe_file_name(str(f.get("name") or f.get("filename") or "budget.csv"))
    52|        profile = None
    53|        rows_total = 0
    54|        errors: list[str] = []
    55|        text = _file_text(f)
    56|        try:
    57|            if text.strip():
    58|                parsed = parse_budget_csv_text(text)
    59|                profile = parsed["profile"]
    60|                rows_total = len(parsed["rows"])
    61|            else:
    62|                lowered = name.lower()
    63|                if "visa" in lowered:
    64|                    profile = "visa_credit_card"
    65|                elif "migros" in lowered or "cumulus" in lowered:
    66|                    profile = "migros_receipts"
    67|                elif "raiffeisen" in lowered:
    68|                    profile = "raiffeisen_bank"
    69|                elif "akb" in lowered or "kantonal" in lowered:
    70|                    profile = "akb_bank"
    71|        except Exception as exc:  # malformed file remains visible, not fatal
    72|            errors.append(type(exc).__name__)
    73|        scanned.append({
    74|            "file_id": str(f.get("id") or f.get("file_id") or ""),
    75|            "name": name,
    76|            "modifiedTime": f.get("modifiedTime") or f.get("modified_time"),
    77|            "profile": profile,
    78|            "source": _source_from_profile(profile, name),
    79|            "rows_total": rows_total,
    80|            "status": "recognized" if profile else "unsupported",
    81|            "errors": errors,
    82|        })
    83|    return {"purpose": "budget_monthly_import_drive_scan", "folder": "Finanzen/03 Budget", "files": scanned, "recognized_count": sum(1 for f in scanned if f["profile"])}
    84|
    85|
    86|def scan_google_drive_budget_folder() -> dict[str, Any]:
    87|    """Read-only metadata scan via gog if available. It never downloads/imports contents."""
    88|    cmd = ["gog", "-a", "friday.uplink@gmail.com", "drive", "search", "Finanzen/03 Budget"]
    89|    try:
    90|        proc = subprocess.run(cmd, text=True, capture_output=True, timeout=45, check=False)
    91|        return {"purpose": "budget_monthly_import_drive_scan", "folder": "Finanzen/03 Budget", "status": "available" if proc.returncode == 0 else "unavailable", "raw_count_hint": len(proc.stdout.splitlines()), "files": []}
    92|    except Exception as exc:
    93|        return {"purpose": "budget_monthly_import_drive_scan", "folder": "Finanzen/03 Budget", "status": "unavailable", "error": type(exc).__name__, "files": []}
    94|
    95|
    96|def _review_url(source: str | None = None, session_id: str | None = None, status: str | None = None) -> str:
    97|    params = {"source": source or "", "import_session": session_id or "", "status": status or ""}
    98|    return "/planning/budget/expenses/review?" + urlencode({k: v for k, v in params.items() if v})
    99|
   100|
   101|def _insert_session(conn: Connection, *, file: dict[str, Any], profile: str | None, rows_total: int, result: dict[str, Any], status: str, review_url: str) -> str:
   102|    sid = new_id("bimport")
   103|    ts = now()
   104|    source = _source_from_profile(profile, str(file.get("name") or ""))
   105|    conn.execute(
   106|        """
   107|        INSERT INTO budget_import_sessions(import_session_id,source,file_id,file_name,file_modified_time,file_period_start,file_period_end,profile,rows_total,new_candidates,already_known,duplicate_count,ignored_count,covered_by_source_count,error_count,status,review_url,created_at,updated_at)
   108|        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   109|        """,
   110|        (
   111|            sid, source, str(file.get("id") or file.get("file_id") or ""), _safe_file_name(str(file.get("name") or "budget.csv")), file.get("modifiedTime") or file.get("modified_time"),
   112|            result.get("period_start"), result.get("period_end"), profile, int(rows_total or 0), int(result.get("candidate_count") or result.get("would_create_candidate_count") or result.get("receipt_candidate_count") or 0), int(result.get("already_processed_count") or 0), int(result.get("possible_duplicate_count") or result.get("duplicate_count") or 0), int(result.get("ignored_count") or 0), int(result.get("covered_by_migros_count") or result.get("covered_by_source_count") or 0), int(result.get("error_count") or 0), status, review_url, ts, ts,
   113|        ),
   114|    )
   115|    return sid
   116|
   117|
   118|def _sum_totals(items: list[dict[str, Any]]) -> dict[str, int]:
   119|    keys = ["would_create_candidate_count", "candidate_count", "already_processed_count", "possible_duplicate_count", "ignored_count", "covered_by_source_count", "receipt_candidate_count", "line_item_count", "income_candidate_count", "transfer_candidate_count"]
   120|    return {k: sum(int(i.get(k) or 0) for i in items) for k in keys}
   121|
   122|
   123|def preview_drive_monthly_import(conn: Connection, files: list[dict[str, Any]], *, dry_run: bool = True, default_category_id: str | None = None, income_category_id: str | None = None) -> dict[str, Any]:
   124|    file_results: list[dict[str, Any]] = []
   125|    scan = scan_budget_drive_files(files)
   126|    session_ids: list[str] = []
   127|    for scanned, f in zip(scan["files"], files):
   128|        profile = scanned.get("profile")
   129|        if not profile:
   130|            result = {"profile": None, "error_count": 1, "ignored_count": 1}
   131|            status = "failed"
   132|        else:
   133|            result = import_budget_csv_text(conn, str(profile), _file_text(f), source_file_label=_safe_file_name(str(f.get("name") or "budget.csv")), dry_run=dry_run, default_category_id=default_category_id, income_category_id=income_category_id)
   134|            status = "dry_run" if dry_run else "candidates_created"
   135|        review = _review_url(scanned.get("source"), None, "ready_for_confirm")
   136|        sid = _insert_session(conn, file=f, profile=profile, rows_total=int(scanned.get("rows_total") or result.get("parsed_rows") or 0), result=result, status=status, review_url=review)
   137|        session_ids.append(sid)
   138|        if not dry_run:
   139|            conn.execute("UPDATE budget_transaction_candidates SET import_session_id=? WHERE source_file_label=? AND import_session_id IS NULL", (sid, _safe_file_name(str(f.get("name") or "budget.csv"))))
   140|        result = dict(result) | {"import_session_id": sid, "source": scanned.get("source"), "file_name": scanned.get("name"), "review_url": _review_url(scanned.get("source"), sid, "ready_for_confirm")}
   141|        file_results.append(result)
   142|    conn.commit()
   143|    return {"purpose": "budget_monthly_import_workflow_v1", "status": "dry_run" if dry_run else "candidates_created", "files_total": len(files), "session_ids": session_ids, "files": file_results, "totals": _sum_totals(file_results), "review_url": _review_url(session_id=session_ids[-1] if session_ids else None, status="ready_for_confirm")}
   144|
   145|
   146|def run_monthly_import_dry_run(conn: Connection, files: list[dict[str, Any]], *, default_category_id: str | None = None, income_category_id: str | None = None) -> dict[str, Any]:
   147|    return preview_drive_monthly_import(conn, files, dry_run=True, default_category_id=default_category_id, income_category_id=income_category_id)
   148|
   149|
   150|def get_import_history(conn: Connection, *, limit: int = 30) -> list[dict[str, Any]]:
   151|    rows = conn.execute("SELECT * FROM budget_import_sessions ORDER BY created_at DESC, import_session_id DESC LIMIT ?", (limit,)).fetchall()
   152|    return [row_to_dict(r) for r in rows]
   153|
   154|
   155|def get_monthly_import_dashboard(conn: Connection, *, budget_year: str = "2026") -> dict[str, Any]:
   156|    sessions = get_import_history(conn, limit=100)
   157|    latest_by_source: dict[str, dict[str, Any]] = {}
   158|    for s in sessions:
   159|        latest_by_source.setdefault(str(s.get("source") or "unknown"), s)
   160|    user_counts = candidate_user_count_summary(conn, budget_year=budget_year)
   161|    open_rows = conn.execute("SELECT source_type,status,classification,COUNT(*) AS count FROM budget_transaction_candidates WHERE substr(COALESCE(transaction_date,''),1,4)=? AND status IN ('pending','auto_categorized','needs_review','transfer_candidate') GROUP BY source_type,status,classification", (budget_year,)).fetchall()
   162|    by_source: dict[str, int] = defaultdict(int)
   163|    counts = Counter(user_counts)
   164|    for r in open_rows:
   165|        by_source[str(r["source_type"] or "unknown")] += int(r["count"] or 0)
   166|        if r["status"]:
   167|            counts[str(r["status"])] += int(r["count"] or 0)
   168|        if r["classification"]:
   169|            counts[str(r["classification"])] += int(r["count"] or 0)
   170|    suggestions = conn.execute("SELECT COUNT(*) FROM budget_rule_suggestions WHERE status IN ('suggested','later')").fetchone()[0]
   171|    return {"purpose": "budget_import_dashboard_v2", "workflow": "upload", "latest_import_by_source": latest_by_source, "open_candidates_by_source": dict(by_source), "counts": dict(counts), "user_counts": user_counts, "rule_suggestions_open": int(suggestions or 0), "review_url": _review_url(status="open")}
   172|
   173|
   174|def _candidate(conn: Connection, candidate_id: str):
   175|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   176|    if not row:
   177|        raise ValueError("candidate not found")
   178|    return row
   179|
   180|
   181|def _affected_count(conn: Connection, merchant_pattern: str, source_scope: str, exclude_id: str | None = None) -> int:
   182|    source_clause = "" if source_scope in {"", "all"} else " AND lower(source_type) LIKE ?"
   183|    params: list[Any] = [f"%{_normalise(merchant_pattern)}%", f"%{_normalise(merchant_pattern)}%"]
   184|    if source_clause:
   185|        params.append(f"%{source_scope.lower()}%")
   186|    if exclude_id:
   187|        source_clause += " AND transaction_candidate_id<>?"; params.append(exclude_id)
   188|    sql = "SELECT COUNT(*) FROM budget_transaction_candidates WHERE status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded') AND (lower(COALESCE(merchant,'')) LIKE ? OR lower(COALESCE(description,'')) LIKE ?)" + source_clause
   189|    return int(conn.execute(sql, params).fetchone()[0] or 0)
   190|
   191|
   192|def create_rule_suggestion_from_candidate_change(conn: Connection, candidate_id: str, *, category_id: str, source_scope: str = "all", recurring_type: str | None = None) -> dict[str, Any]:
   193|    row = _candidate(conn, candidate_id)
   194|    cat = conn.execute("SELECT name FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
   195|    merchant = str(row["merchant"] or row["description"] or "").strip()
   196|    pattern = _normalized_merchant_name(merchant).upper() or merchant.upper()
   197|    affected = _affected_count(conn, pattern, source_scope, exclude_id=candidate_id)
   198|    sid = new_id("rulesug")
   199|    ts = now()
   200|    conn.execute("""
   201|        INSERT INTO budget_rule_suggestions(suggestion_id,example_candidate_id,merchant_pattern,description_pattern,match_type,source_scope,category_id,category_name,recurring_type,amount_min_text,amount_max_text,confidence,affected_open_candidate_count,status,notes,created_at,updated_at)
   202|        VALUES (?, ?, ?, ?, 'contains', ?, ?, ?, ?, NULL, NULL, ?, ?, 'suggested', ?, ?, ?)
   203|    """, (sid, candidate_id, pattern, str(row["description"] or "")[:180], source_scope or "all", category_id, cat["name"] if cat else None, recurring_type, "0.84" if affected else "0.70", affected, json.dumps({"trigger": "manual_category_change"}), ts, ts))
   204|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_suggestion_created", entity_type="budget_rule_suggestion", entity_id=sid, new_values={"merchant_pattern": pattern, "category_id": category_id, "source_scope": source_scope}, created_by="user")
   205|    conn.commit()
   206|    return row_to_dict(conn.execute("SELECT * FROM budget_rule_suggestions WHERE suggestion_id=?", (sid,)).fetchone()) | {"audit_id": audit_id}
   207|
   208|
   209|def list_rule_suggestions(conn: Connection, *, status: str | None = None) -> list[dict[str, Any]]:
   210|    if status:
   211|        rows = conn.execute("SELECT * FROM budget_rule_suggestions WHERE status=? ORDER BY created_at DESC", (status,)).fetchall()
   212|    else:
   213|        rows = conn.execute("SELECT * FROM budget_rule_suggestions ORDER BY created_at DESC").fetchall()
   214|    return [row_to_dict(r) for r in rows]
   215|
   216|
   217|def apply_rule_suggestion(conn: Connection, suggestion_id: str, *, apply_to_open: bool = False) -> dict[str, Any]:
   218|    row = conn.execute("SELECT * FROM budget_rule_suggestions WHERE suggestion_id=?", (suggestion_id,)).fetchone()
   219|    if not row:
   220|        raise ValueError("rule suggestion not found")
   221|    rule = create_budget_rule(conn, {"merchant_contains": row["merchant_pattern"], "source_type": None if row["source_scope"] in {"", "all"} else row["source_scope"], "category_id": row["category_id"], "target_status": "review", "notes": "created_from_rule_suggestion"})
   222|    updated = 0
   223|    if apply_to_open:
   224|        params: list[Any] = [f"%{_normalise(row['merchant_pattern'])}%", f"%{_normalise(row['merchant_pattern'])}%"]
   225|        source_clause = ""
   226|        if row["source_scope"] not in {"", "all", None}:
   227|            source_clause = " AND lower(source_type) LIKE ?"; params.append(f"%{str(row['source_scope']).lower()}%")
   228|        params.extend([row["category_id"], row["category_name"], rule["rule_id"], row["merchant_pattern"], now()])
   229|        sql = """
   230|            UPDATE budget_transaction_candidates
   231|            SET proposed_category_id=?, proposed_category_name=?, rule_id=?, rule_name=?, updated_at=?
   232|            WHERE status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','superseded')
   233|              AND (lower(COALESCE(merchant,'')) LIKE ? OR lower(COALESCE(description,'')) LIKE ?)
   234|        """
   235|        # SQLite parameter order: SET params first, WHERE params last
   236|        set_params = [row["category_id"], row["category_name"], rule["rule_id"], row["merchant_pattern"], now()]
   237|        where_params = [f"%{_normalise(row['merchant_pattern'])}%", f"%{_normalise(row['merchant_pattern'])}%"]
   238|        if source_clause:
   239|            where_params.append(f"%{str(row['source_scope']).lower()}%")
   240|        cur = conn.execute(sql + source_clause, set_params + where_params)
   241|        updated = int(cur.rowcount or 0)
   242|    conn.execute("UPDATE budget_rule_suggestions SET status='accepted', updated_at=? WHERE suggestion_id=?", (now(), suggestion_id))
   243|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_suggestion_accepted", entity_type="budget_rule_suggestion", entity_id=suggestion_id, new_values={"apply_to_open": apply_to_open, "updated_candidate_count": updated}, created_by="user")
   244|    conn.commit()
   245|    return {"status": "accepted", "suggestion_id": suggestion_id, "rule_id": rule["rule_id"], "updated_candidate_count": updated, "audit_id": audit_id}
   246|
   247|
   248|def reject_rule_suggestion(conn: Connection, suggestion_id: str, *, later: bool = False) -> dict[str, Any]:
   249|    status = "later" if later else "rejected"
   250|    conn.execute("UPDATE budget_rule_suggestions SET status=?, updated_at=? WHERE suggestion_id=?", (status, now(), suggestion_id))
   251|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_rule_suggestion_rejected" if not later else "budget_rule_suggestion_later", entity_type="budget_rule_suggestion", entity_id=suggestion_id, new_values={"status": status}, created_by="user")
   252|    conn.commit()
   253|    return {"status": status, "suggestion_id": suggestion_id, "audit_id": audit_id}
   254|


     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_budget_category_cleanup,
    12|    confirm_category_merge,
    13|    confirm_create_category,
    14|    confirm_create_tag,
    15|    create_category_preview,
    16|    create_tag_preview,
    17|    delete_empty_category,
    18|    list_category_tree_grouped,
    19|    list_categories,
    20|    list_tags,
    21|    preview_budget_category_cleanup,
    22|    preview_category_merges,
    23|    reorder_categories,
    24|    update_category,
    25|)
    26|from jarvis_finance.services.budget_csv_imports import import_budget_csv_text, import_profile_specs
    27|from jarvis_finance.services.budget_import_upload import (
    28|    confirm_uploaded_budget_import_candidates,
    29|    get_budget_import_session,
    30|    list_budget_import_sessions,
    31|    preview_uploaded_budget_import,
    32|)
    33|from jarvis_finance.services.budget_imports import (
    34|    archive_reference_year_candidates,
    35|    apply_budget_rules_to_open_candidates,
    36|    apply_merchant_to_open_candidates,
    37|    batch_confirm_safe_candidates,
    38|    batch_mark_candidates,
    39|    confirm_migros_receipts_2026,
    40|    bulk_update_transaction_candidate_category,
    41|    confirm_candidate_split,
    42|    confirm_transaction_candidate,
    43|    create_budget_rule,
    44|    create_merchant,
    45|    create_merchant_alias,
    46|    create_candidate_split,
    47|    create_review_category_for_candidate,
    48|    deactivate_budget_rule,
    49|    detect_duplicate_candidates,
    50|    ignore_transaction_candidate,
    51|    list_budget_rules,
    52|    list_merchants,
    53|    list_transaction_candidates,
    54|    merge_merchants,
    55|    mark_candidate_transfer,
    56|    mark_candidate_type,
    57|    preview_migros_receipts_2026,
    58|    preview_batch_confirm_candidates,
    59|    preview_safe_auto_candidates,
    60|    preview_transaction_candidate,
    61|    prioritize_budget_rule,
    62|    reclassify_bank_income_candidates,
    63|    reopen_transaction_candidate,
    64|    review_candidate_summary,
    65|    seed_bank_transfer_candidates_from_rows,
    66|    seed_credit_card_candidates_from_rows,
    67|    seed_migros_candidates_from_rows,
    68|    seed_transaction_candidates_from_csv_text,
    69|    seed_transaction_candidates_from_rows,
    70|    test_budget_rule,
    71|    update_budget_rule,
    72|    update_merchant_category,
    73|    update_transaction_candidate_category,
    74|)
    75|from jarvis_finance.services.budget_analytics import get_monthly_comparison
    76|from jarvis_finance.services.budget_analytics_v2 import get_budget_dashboard_cockpit, get_budget_vs_actual_analysis
    77|from jarvis_finance.services.budget_data_explorer import get_budget_data_explorer, get_budget_deviation_analysis, get_category_analysis_v2, get_merchant_analysis
    78|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
    79|from jarvis_finance.services.budget_planning import (
    80|    confirm_budget_planning_batch,
    81|    confirm_category_budget_values,
    82|    get_budget_planning_audit_timeline,
    83|    get_budget_planning_matrix,
    84|    preview_budget_planning_batch,
    85|    preview_budget_planning_excel_template,
    86|    preview_category_budget_values,
    87|)
    88|from jarvis_finance.services.budget_recurring import (
    89|    archive_recurring_payment,
    90|    confirm_manual_recurring_payment,
    91|    confirm_recurring_candidate_action,
    92|    detect_recurring_payment_candidates,
    93|    get_recurring_dashboard,
    94|    preview_manual_recurring_payment,
    95|    preview_recurring_candidate_action,
    96|)
    97|from jarvis_finance.services.budget_monthly_import import (
    98|    apply_rule_suggestion,
    99|    get_import_history,
   100|    get_monthly_import_dashboard,
   101|    list_rule_suggestions,
   102|    preview_drive_monthly_import,
   103|    reject_rule_suggestion,
   104|    run_monthly_import_dry_run,
   105|    scan_budget_drive_files,
   106|    scan_google_drive_budget_folder,
   107|)
   108|from jarvis_finance.services.grocery_optimizer import (
   109|    accept_grocery_product_match,
   110|    add_demo_migros_receipt,
   111|    build_rappn_link_out,
   112|    confirm_manual_grocery_mapping,
   113|    create_manual_grocery_mapping,
   114|    generate_grocery_optimization_report,
   115|    get_grocery_optimizer_dashboard,
   116|    get_grocery_provider_strategy,
   117|    preview_manual_grocery_mapping,
   118|    refresh_grocery_mapping_price,
   119|    reject_grocery_product_match,
   120|    run_grocery_optimization,
   121|    upsert_product_match,
   122|)
   123|from jarvis_finance.services.grocery_price_providers import provider_for_retailer, search_and_store_product_matches, search_open_prices_spike
   124|from jarvis_finance.services.budget_plans import (
   125|    archive_budget_plan_item,
   126|    confirm_budget_plan_item,
   127|    confirm_income_plan_templates,
   128|    confirm_update_income_plan_item,
   129|    create_budget_plan_item_preview,
   130|    list_budget_plan_items,
   131|    list_income_plan_items,
   132|    list_income_seed_candidates,
   133|    preview_income_plan_templates,
   134|    preview_update_income_plan_item,
   135|    reorder_income_plan_items,
   136|)
   137|from jarvis_finance.services.budget_seed_review import (
   138|    confirm_budget_plan_takeover,
   139|    confirm_clear_category_takeover,
   140|    confirm_seed_candidate,
   141|    convert_unclean_range,
   142|    ignore_seed_candidate,
   143|    list_seed_candidates,
   144|    preview_budget_plan_takeover,
   145|    preview_clear_category_takeover,
   146|    preview_seed_candidate,
   147|    reopen_seed_candidate,
   148|    seed_budget_candidates_from_rows,
   149|    set_seed_candidate_status,
   150|)
   151|from jarvis_finance.services.budget_transactions import (
   152|    confirm_budget_transaction,
   153|    confirm_reverse_budget_transaction,
   154|    confirm_transfer,
   155|    create_budget_transaction_preview,
   156|    create_transfer_preview,
   157|    list_budget_transactions,
   158|    list_budget_transfers,
   159|    reverse_budget_transaction_preview,
   160|    update_budget_transaction,
   161|)
   162|
   163|router = APIRouter(prefix="/budget", tags=["budget"])
   164|
   165|
   166|@router.get("/grocery-optimizer/dashboard")
   167|def grocery_optimizer_dashboard(conn: Connection = Depends(get_db)):
   168|    return get_grocery_optimizer_dashboard(conn)
   169|
   170|
   171|@router.get("/grocery-optimizer/provider-strategy")
   172|def grocery_optimizer_provider_strategy():
   173|    return get_grocery_provider_strategy()
   174|
   175|
   176|@router.get("/grocery-optimizer/rappn-link")
   177|def grocery_optimizer_rappn_link(product_name: str):
   178|    return build_rappn_link_out(product_name)
   179|
   180|
   181|@router.post("/grocery-optimizer/open-prices-spike")
   182|def grocery_optimizer_open_prices_spike(payload: dict):
   183|    return search_open_prices_spike(query=payload.get("query"), barcode=payload.get("barcode"), size=int(payload.get("size") or 5))
   184|
   185|
   186|@router.post("/grocery-optimizer/demo-receipt")
   187|def grocery_optimizer_demo_receipt(payload: dict, conn: Connection = Depends(get_db)):
   188|    return add_demo_migros_receipt(conn, purchase_date=payload.get("purchase_date") or "2026-05-19", store_name=payload.get("store_name") or "Migros", items=payload.get("items") or [])
   189|
   190|
   191|@router.post("/grocery-optimizer/matches")
   192|def grocery_optimizer_match(payload: dict, conn: Connection = Depends(get_db)):
   193|    return upsert_product_match(conn, payload["product_item_id"], retailer=payload["retailer"], candidate_product_name=payload["candidate_product_name"], candidate_url=payload.get("candidate_url") or "https://example.invalid/review", candidate_price_text=payload["candidate_price_text"], candidate_unit_price_text=payload.get("candidate_unit_price_text"), quality_flags=payload.get("quality_flags") or ["needs_review"], match_confidence=payload.get("match_confidence") or "0.60", status=payload.get("status") or "suggested", source=payload.get("source") or "manual")
   194|
   195|
   196|@router.post("/grocery-optimizer/matches/{match_id}/accept")
   197|def grocery_optimizer_accept_match(match_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   198|    payload = payload or {}
   199|    return accept_grocery_product_match(conn, match_id, user_note=payload.get("user_note"), save_as_preferred=bool(payload.get("save_as_preferred", True)))
   200|
   201|
   202|@router.post("/grocery-optimizer/matches/{match_id}/reject")
   203|def grocery_optimizer_reject_match(match_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   204|    return reject_grocery_product_match(conn, match_id, user_note=(payload or {}).get("user_note"))
   205|
   206|
   207|@router.post("/grocery-optimizer/manual-mapping/preview")
   208|def grocery_optimizer_manual_mapping_preview(payload: dict, conn: Connection = Depends(get_db)):
   209|    return preview_manual_grocery_mapping(conn, product_item_id=payload["product_item_id"], target_retailer=payload["target_retailer"], target_product_name=payload["target_product_name"], target_product_url=payload["target_product_url"], candidate_price_text=payload.get("candidate_price_text"), candidate_unit_price_text=payload.get("candidate_unit_price_text"), target_package_size=payload.get("target_package_size"), target_unit=payload.get("target_unit"), target_price_date=payload.get("target_price_date"), target_price_source=payload.get("target_price_source"), user_note=payload.get("user_note"))
   210|
   211|
   212|@router.post("/grocery-optimizer/manual-mapping/confirm")
   213|def grocery_optimizer_manual_mapping_confirm(payload: dict, conn: Connection = Depends(get_db)):
   214|    return confirm_manual_grocery_mapping(conn, payload)
   215|
   216|
   217|@router.post("/grocery-optimizer/manual-mapping")
   218|def grocery_optimizer_manual_mapping(payload: dict, conn: Connection = Depends(get_db)):
   219|    return create_manual_grocery_mapping(conn, product_item_id=payload["product_item_id"], target_retailer=payload["target_retailer"], target_product_name=payload["target_product_name"], target_product_url=payload["target_product_url"], target_brand=payload.get("target_brand"), target_package_size=payload.get("target_package_size"), target_unit=payload.get("target_unit"), target_price_date=payload.get("target_price_date"), target_price_source=payload.get("target_price_source"), match_type=payload.get("match_type") or "manual_match", candidate_price_text=payload.get("candidate_price_text"), candidate_unit_price_text=payload.get("candidate_unit_price_text"), user_note=payload.get("user_note"))
   220|
   221|
   222|@router.post("/grocery-optimizer/mappings/{mapping_id}/refresh-price")
   223|def grocery_optimizer_refresh_mapping_price(mapping_id: str, conn: Connection = Depends(get_db)):
   224|    return refresh_grocery_mapping_price(conn, mapping_id)
   225|
   226|
   227|@router.post("/grocery-optimizer/optimize")
   228|def grocery_optimizer_run(payload: dict, conn: Connection = Depends(get_db)):
   229|    return run_grocery_optimization(conn, payload["receipt_id"], selected_retailers=payload.get("selected_retailers") or ["Coop", "Aldi Suisse", "Lidl", "Denner", "Otto's"], max_store_count=int(payload.get("max_store_count") or 3), included_product_item_ids=payload.get("included_product_item_ids"))
   230|
   231|
   232|@router.post("/grocery-optimizer/search-prices")
   233|def grocery_optimizer_search_prices(payload: dict, conn: Connection = Depends(get_db)):
   234|    retailers = (payload.get("retailers") or ["Coop"])[: int(payload.get("max_retailers") or 3)]
   235|    providers = [provider_for_retailer(retailer) for retailer in retailers]
   236|    return search_and_store_product_matches(
   237|        conn,
   238|        payload["receipt_id"],
   239|        providers=providers,
   240|        included_product_item_ids=payload.get("included_product_item_ids"),
   241|        max_products=int(payload.get("max_products") or 5),
   242|        use_cache=bool(payload.get("use_cache", True)),
   243|        only_safe_matches=bool(payload.get("only_safe_matches", True)),
   244|        prefer_known_mappings=bool(payload.get("prefer_known_mappings", True)),
   245|    )
   246|
   247|
   248|@router.post("/grocery-optimizer/report")
   249|def grocery_optimizer_report(payload: dict, conn: Connection = Depends(get_db)):
   250|    return generate_grocery_optimization_report(conn, payload["run_id"])
   251|
   252|
   253|@router.get("/monthly-import/dashboard")
   254|def monthly_import_dashboard(budget_year: str = "2026", conn: Connection = Depends(get_db)):
   255|    return get_monthly_import_dashboard(conn, budget_year=budget_year)
   256|
   257|
   258|@router.get("/monthly-import/history")
   259|def monthly_import_history(limit: int = 30, conn: Connection = Depends(get_db)):
   260|    return get_import_history(conn, limit=limit)
   261|

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


1|<template>
     2|

     3|
     4|
Ausgaben · Buchungen prüfen

     5|
Buchungen prüfen

     6|
Import-Kandidaten mit Quellentabs, Transferlogik und auditierbaren Aktionen. Erst Confirm erzeugt eine Budgetbuchung; Transfers zählen nicht als Einnahmen/Ausgaben.

     7|
     8|
     9|    <Message v-if="message" severity="success" :closable="false">{{ message }}</Message>
    10|    <Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
    11|
    12|

    13|      <Card v-for="card in summaryCards" :key="card.label" data-test="candidate-summary-card" :title="card.help">
    14|        <template #content>
{{ card.label }}

{{ card.value }}

{{ card.help }}
</template>
    15|      </Card>
    16|

    17|
    18|    <Card>
    19|      <template #content>
    20|        <Tabs v-model:value="activeTab" @update:value="setTab(String($event))">
    21|          <TabList><Tab v-for="tab in tabs" :key="tab.key" :value="tab.key">{{ tab.label }}</Tab></TabList>
    22|        </Tabs>
    23|        <Toolbar class="mt-4">
    24|          <template #start>

    25|            <Select v-model="confirmAccount" :options="accountOptions.filter(o => o.value)" optionLabel="label" optionValue="value" placeholder="Confirm-Konto" class="w-48" />
    26|            <Select v-model="filters.budget_year" :options="yearOptions" optionLabel="label" optionValue="value" class="w-40" />
    27|            <Select v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Status" class="w-44" />
    28|            <Select v-model="bulkCategory" :options="categoryOptions" optionLabel="label" optionValue="value" placeholder="Bulk-Kategorie" filter class="w-56" />
    29|            <InputText v-model="filters.merchant" placeholder="Händler" class="w-36" />
    30|            <InputText v-model="filters.source_type" placeholder="Quelle" class="w-36" />
    31|            <Button label="Filtern" icon="pi pi-filter" severity="secondary" @click="load" />
    32|            <Button label="Bankeingänge neu klassifizieren" icon="pi pi-refresh" severity="info" @click="reclassifyIncome" />
    33|
</template>
    34|        </Toolbar>
    35|        <Toolbar class="mt-3">
    36|          <template #start>
<Tag data-test="bulk-selection-count" :value="`Ausgewählt: ${selectedIds.length}`" severity="info" rounded /><Button data-test="bulk-apply-category" label="Kategorie auf Auswahl anwenden" severity="secondary" :disabled="!selectedIds.length || !bulkCategory" @click="bulkSetCategory" /><Button data-test="bulk-confirm-preview" label="Auswahl bestätigen" :disabled="!selectedIds.length" @click="previewBatchConfirm" /><Button data-test="bulk-ignore" label="Ignorieren" severity="secondary" :disabled="!selectedIds.length" @click="bulkIgnore" /><Button data-test="bulk-reopen" label="Wieder öffnen" severity="secondary" :disabled="!selectedIds.length" @click="bulkReopen" /><Button label="Transfer" severity="warn" :disabled="!selectedIds.length" @click="bulkTransfer" />
</template>
    37|        </Toolbar>
    38|
    39|        <Message v-if="batchPreview" class="mt-3" severity="warn" :closable="false" data-test="batch-confirm-preview">Bulk Confirm Preview: {{ batchPreview.count }} bestätigungsfähig · Blocker: {{ batchPreview.conflicts.length }} <Button class="ml-2" size="small" label="Details öffnen" @click="batchDialogVisible=true" /></Message>
    40|        <Message v-if="batchErrors.length" class="mt-3" severity="error" :closable="false">Fehler pro Kandidat: {{ batchErrors.length }}

{{ err.error }}

</Message>
    41|
    42|        <DataTable :value="candidates" data-test="candidate-review-table" size="small" paginator :rows="25" responsiveLayout="scroll" dataKey="transaction_candidate_id" class="mt-4">
    43|          <Column headerStyle="width:3rem"><template #header><input type="checkbox" :checked="allVisibleSelected" @change="toggleAll" /></template><template #body="slotProps"><input v-model="selected" :value="slotProps.data.transaction_candidate_id" type="checkbox" /></template></Column>
    44|          <Column field="transaction_date" header="Datum" sortable><template #body="slotProps">{{ slotProps.data.transaction_date || '—' }}</template></Column>
    45|          <Column header="Quelle"><template #body="slotProps">{{ slotProps.data.source_type }}{{ slotProps.data.source_file_label }}</template></Column>
    46|          <Column header="Händler/Beschreibung"><template #body="slotProps">{{ slotProps.data.merchant || slotProps.data.description }}{{ slotProps.data.description }}</template></Column>
    47|          <Column header="Betrag" bodyClass="amount-cell"><template #body="slotProps">{{ slotProps.data.amount_original || '—' }} {{ slotProps.data.currency_original }}</template></Column>
    48|          <Column header="Kategorie"><template #body="slotProps"><Select :modelValue="slotProps.data.proposed_category_id || ''" :options="categoryOptions" optionLabel="label" optionValue="value" filter placeholder="Review nötig" class="w-56" @update:modelValue="setCategory(slotProps.data, String($event))" /><Button class="mt-1" label="+ Kategorie" size="small" text @click="openNewCategory(slotProps.data)" /></template></Column>
    49|          <Column header="Typ"><template #body="slotProps"><Tag :value="typeLabel(slotProps.data)" severity="info" rounded />{{ transferHint(slotProps.data) }}</template></Column>
    50|          <Column header="Confidence"><template #body="slotProps"><Tag :value="slotProps.data.confidence" :severity="confidenceSeverity(slotProps.data.confidence)" rounded /></template></Column>
    51|          <Column header="Grund / Geldfluss"><template #body="slotProps"><Tag :value="slotProps.data.rule_name || slotProps.data.classification || '—'" severity="secondary" />{{ slotProps.data.review_reason || slotProps.data.notes }}Von Konto: {{ slotProps.data.account_source || slotProps.data.source_type }} · Zu Konto: {{ suggestedTargetAccount(slotProps.data) }}Gegenbuchung gefunden: {{ counterBookingHint(slotProps.data) }}</template></Column>
    52|          <Column header="Status"><template #body="slotProps"><Tag :value="statusLabel(slotProps.data.status)" :severity="statusSeverity(slotProps.data.status)" rounded /></template></Column>
    53|          <Column header="Aktionen"><template #body="slotProps">
<Button v-if="canConfirm(slotProps.data)" label="Bestätigen" size="small" @click="confirmItem(slotProps.data)" /><Button v-if="canForceConfirmDuplicate(slotProps.data)" label="Trotzdem bestätigen" size="small" severity="warn" @click="confirmItem(slotProps.data, true)" /><Button label="Ignorieren" size="small" severity="secondary" @click="ignoreItem(slotProps.data.transaction_candidate_id)" /><Button label="Transfer" size="small" severity="warn" @click="markType(slotProps.data, 'transfer')" /><Button label="Investment Transfer" size="small" severity="warn" @click="markType(slotProps.data, 'investment_transfer')" /><Button label="Kreditkartenausgleich" size="small" severity="warn" @click="markType(slotProps.data, 'credit_card_payment')" /><Button label="Splitten" size="small" severity="info" @click="openSplit(slotProps.data)" /><Button label="Details" size="small" text @click="detail=slotProps.data" />
</template></Column>
    54|          <template #empty>Keine Kandidaten.</template>
    55|        </DataTable>
    56|      </template>
    57|    </Card>
    58|
    59|    <Dialog v-model:visible="detailVisible" modal header="Kandidat Details" :style="{ width: 'min(720px, 96vw)' }">
    60|

{{ detail.merchant || detail.description }}

{{ row[0] }}

{{ row[1] || '—' }}

    61|

Neue Kategorie erstellen
<InputText v-model="newCategory.name" class="mt-3 w-full" placeholder="Name" /><Select v-model="newCategory.parent_category_id" :options="categoryOptions" optionLabel="label" optionValue="value" class="mt-3 w-full" placeholder="Parent" /><Select v-model="newCategory.category_type" :options="['expense','income','transfer','neutral']" class="mt-3 w-full" /><Button class="mt-3" label="Kategorie erstellen & zuordnen" @click="createCategoryFromReview" />

    62|

Split erstellen

<Select v-model="split.category_id" :options="categoryOptions" optionLabel="label" optionValue="value" placeholder="Kategorie" /><InputText v-model="split.amount_original" placeholder="Betrag" /><InputText v-model="split.tag" placeholder="Tag" /><InputText v-model="split.note" placeholder="Notiz" />

Split-Summe: {{ splitTotalText }} · Differenz: {{ splitDiffText }}
<Button class="mt-3" label="Zeile +" severity="secondary" @click="splits.push({ category_id:'', amount_original:'', tag:'', note:'' })" /><Button class="ml-2 mt-3" label="Split Preview/Confirm" :disabled="!splitMatches" @click="confirmSplit" />

    63|    </Dialog>
    64|    <Dialog v-model:visible="batchDialogVisible" modal header="Auswahl bestätigen" :style="{ width: 'min(760px, 96vw)' }" data-test="bulk-confirm-dialog">
    65|

    66|

    67|

Kandidaten

{{ batchPreview.count }}

    68|

Blocker

{{ batchPreview.conflicts.length }}

    69|

Mögliche Duplikate

{{ batchPreview.possible_duplicates || batchPreview.blockers?.duplicate_candidates || 0 }}

    70|

    71|

Kategorien

{{ name }}: {{ count }}

Quellen

{{ name }}: {{ count }}

    72|

Blocker & Warnungen

Keine Blocker. Confirm ist möglich.

{{ conflict.reason }}

    73|
<Button label="Abbrechen" severity="secondary" @click="batchDialogVisible=false" /><Button data-test="bulk-confirm-final" label="Final bestätigen" :disabled="!batchPreview.can_confirm" @click="confirmBatchPreview" />

    74|

    75|    </Dialog>
    76|

    77|</template>
    78|
   168|


Explanation:
**Looking into function details**

I need to read the rest of the functions in the budget_imports to confirm the batch list. It's important to ensure I understand how everything works together. I’m curious if all elements are functioning as expected. I want to double-check any details that could affect the overall process. It's a necessary step to avoid mistakes down the line! Let’s get through this so I can provide accurate and helpful outputs for the user.

python - <<'PY'
from pathlib import Path
p=Path('src/jarvis_finance/services/budget_imports.py')
for i,l in enumerate(p.read_text().splitlines(),1):
    if any(x in l for x in ['def list_transaction_candidates','def confirm_transaction_candidate','def preview_batch_confirm','def batch_confirm','def detect_duplicate','def seed_migros','def batch_mark','def ignore_transaction','def reopen_transaction']): print(i,l)
PY


Output:
304 def seed_migros_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, auto_category_id: str | None = None, threshold_chf: str = "50.00", candidate_source_type: str = "migros_receipt") -> dict[str, int]:
761 def list_transaction_candidates(conn: Connection, *, status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, import_session_id: str | None = None) -> list[dict[str, Any]]:
927 def confirm_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
949 def ignore_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
960 def reopen_transaction_candidate(conn: Connection, candidate_id: str, note: str | None = None) -> dict[str, Any]:
1078 def batch_confirm_safe_candidates(conn: Connection, *, account_id: str, candidate_ids: list[str] | None = None) -> dict[str, int]:
1173 def preview_batch_confirm_candidates(conn: Connection, *, candidate_ids: list[str], account_id: str, budget_year: str = "2026") -> dict[str, Any]:
1204 def batch_mark_candidates(conn: Connection, *, candidate_ids: list[str], action: str, account_id: str | None = None, category_id: str | None = None, preview_id: str | None = None, note: str | None = None, budget_year: str = "2026") -> dict[str, Any]:
1506 def detect_duplicate_candidates(conn: Connection, *, day_window: int = 2) -> dict[str, Any]: