JARVIS Finance Dashboard

Running WITHOUT residential proxies. Bot detection may be more aggressive. Consider upgrading Browserbase plan for proxy support.

- complementary
  - paragraph
    - StaticText "JARVIS"
  - heading "Finance" [level=1, ref=e1]
  - navigation
    - link "Command Center" [ref=e2]
    - link "Portfolio" [ref=e3]
    - link "Crypto" [ref=e4]
    - link "Aktien & ETFs" [ref=e5]
    - link "Wallets" [ref=e6]
    - link "Position hinzufügen" [ref=e7]
    - link "Reports" [ref=e8]
    - paragraph
    - link "Budgetstatus nach Kategorie" [ref=e9]
    - link "Einkünfte" [ref=e10]
    - link "Monatsbudget" [ref=e11]
    - link "Jahresbudget" [ref=e12]
    - link "Budgetpläne" [ref=e13]
    - paragraph
    - link "Buchungen prüfen" [ref=e14]
    - link "Effektive Ausgaben" [ref=e15]
    - link "Manuelle Ausgabe" [ref=e16]
    - paragraph
    - link "Kategorien" [ref=e17]
    - link "Regeln" [ref=e18]
    - link "Tags" [ref=e19]
    - link "Konten" [ref=e20]
    - link "Excel übernehmen / Seed Review" [ref=e21]
    - paragraph
    - link "Fixkosten" [ref=e22]
    - link "Duplikate" [ref=e23]
    - link "Migros Details" [ref=e24]
    - link "VISA Import" [ref=e25]
  - sectionfooter
    - paragraph
    - paragraph
    - paragraph
      - StaticText "Arbeitsseiten: Preview/Confirm/Audit"
    - paragraph
      - StaticText "Provider beim Rendern: nein"
- main
  - sectionheader
    - paragraph
      - StaticText "USER MODE V0"
    - heading "Command Center" [level=2, ref=e26]
    - StaticText "Lokal · kein Provider-Render"
  - generic
    - paragraph
      - StaticText "READ-ONLY · LOKALE RUNTIME-DATEN"
    - heading "Command Center" [level=3, ref=e27]
    - paragraph
      - StaticText "Lädt automatisch lokale FastAPI-/Runtime-Daten. Provider-Refreshes bleiben verboten."
    - button "Aktualisieren" [ref=e28]
  - StaticText "FastAPI verbunden: ja"
  - StaticText "Runtime-DB verfügbar: ja"
  - StaticText "Externe Provider beim Rendern: nein"
  - StaticText "Daten geladen · letzter lokaler Datenabruf: 17.5.2026, 18:20:07"
  - generic
    - paragraph
      - StaticText "KPI"
    - paragraph
      - StaticText "Gesamtportfolio CHF"
    - StaticText "CHF"
    - StaticText "CHF 67’709.31"
  - generic
    - paragraph
      - StaticText "KPI"
    - paragraph
      - StaticText "Crypto CHF"
    - StaticText "CHF 47’709.31"
  - generic
    - paragraph
      - StaticText "KPI"
    - paragraph
      - StaticText "Aktien/ETF CHF"
    - StaticText "CHF 0.00"
  - generic
    - paragraph
      - StaticText "KPI"
    - paragraph
      - StaticText "Cash CHF"
    - StaticText "CHF 20’000.00"
  - generic
    - paragraph
      - StaticText "KPI"
    - paragraph
      - StaticText "Tagesänderung"
    - StaticText "—"
    - paragraph
      - StaticText "falls lokal verfügbar"
  - generic
    - paragraph
      - StaticText "KPI"
    - paragraph
      - StaticText "Letztes Preisupdate"
    - StaticText "2026-05-15T19:35:49+00:00"
  - generic
    - generic
      - paragraph
        - StaticText "PORTFOLIO"
      - heading "Asset Allocation" [level=3, ref=e31]
      - StaticText "Crypto"
      - StaticText "47709.31 CHF"
      - StaticText "Aktien & ETFs"
      - StaticText "0.00 CHF"
      - StaticText "Cash"
      - StaticText "20000.00 CHF"
    - generic
      - paragraph
        - StaticText "DATA QUALITY"
      - heading "Hinweise" [level=3, ref=e32]
      - StaticText "Kritisch"
      - DescriptionList
        - term "Letztes Preisupdate"
        - definition
        - term "Lokaler Cash-Abgleich"
        - definition
        - term "Keine externen Provider"
        - definition
  - generic
    - heading "Top Crypto Positionen" [level=3, ref=e29]
    - table
      - rowgroup
        - row
          - columnheader "COIN" [ref=e72]
          - columnheader "SYMBOL" [ref=e73]
          - columnheader "WERT CHF" [ref=e74]
          - columnheader "STATUS" [ref=e75]
      - row [ref=e35] clickable [cursor:pointer]
        - cell "Bitcoin" [ref=e48]
        - cell "BTC" [ref=e49]
        - cell "22905.06" [ref=e50]
        - cell "Veraltet" [ref=e51]
      - row [ref=e36] clickable [cursor:pointer]
        - cell "Tether" [ref=e52]
        - cell "USDT" [ref=e53]
        - cell "10133.26" [ref=e54]
        - cell "Veraltet" [ref=e55]
      - row [ref=e37] clickable [cursor:pointer]
        - cell "Ethereum ETH" [ref=e56]
        - cell "ETH" [ref=e57]
        - cell "4374.58" [ref=e58]
        - cell "Veraltet" [ref=e59]
      - row [ref=e38] clickable [cursor:pointer]
        - cell "Ripple XRP" [ref=e60]
        - cell "XRP" [ref=e61]
        - cell "2824.17" [ref=e62]
        - cell "Veraltet" [ref=e63]
      - row [ref=e39] clickable [cursor:pointer]
        - cell "BNB" [ref=e64]
        - cell "BNB" [ref=e65]
        - cell "2213.36" [ref=e66]
        - cell "Veraltet" [ref=e67]
    - heading "Aktien/ETF/Cash Zusammenfassung" [level=3, ref=e30]
    - table
      - rowgroup
        - row
          - columnheader "BEREICH" [ref=e68]
          - columnheader "WERT CHF" [ref=e69]
          - columnheader "POSITIONEN" [ref=e70]
          - columnheader "STATUS" [ref=e71]
      - row [ref=e33] clickable [cursor:pointer]
        - cell "Aktien & ETFs" [ref=e40]
        - cell "0.00" [ref=e41]
        - cell "5" [ref=e42]
        - cell "Read-only" [ref=e43]
      - row [ref=e34] clickable [cursor:pointer]
        - cell "Cash" [ref=e44]
        - cell "20000.00" [ref=e45]
        - cell "4" [ref=e46]
        - cell "Lokal geladen" [ref=e47]


Sie hatten recht, Sir. Der vorige „Restart“ war offenbar nicht wirksam genug bzw. der Frontend-API-Base war für iPhone/Tailscale nicht sauber verifiziert. Ich habe jetzt **hart neu gestartet und über Tailscale geprüft**.

## Status jetzt

- **Backend neu gestartet:** ja
  - Listener: `127.0.0.1:8000`
  - Prozess: `uvicorn`
  - Runtime-DB: verfügbar

- **Frontend neu gestartet:** ja
  - Listener: `0.0.0.0:5173`
  - Prozess: `node`
  - `VITE_API_BASE_URL`: `http://100.85.29.67:5173`

## Verifizierte Links

- Lokal Frontend: `http://127.0.0.1:5173`
- Lokal Backend Health: `http://127.0.0.1:8000/api/health`
- Tailscale Dashboard: `http://100.85.29.67:5173`
- Tailscale Runtime Status: `http://100.85.29.67:5173/api/runtime/status`

## Verifikation

- `http://127.0.0.1:8000/api/health`: OK
- `http://127.0.0.1:8000/api/runtime/status`: OK
- `http://127.0.0.1:5173/api/runtime/status`: OK
- `http://100.85.29.67:5173/api/runtime/status`: OK
- Browser-Sanity auf `http://100.85.29.67:5173`: OK
- Browser-Konsole: keine JS-Fehler

Runtime meldet korrekt:

```json
{
  "db_available": true,
  "runtime_outside_repo": true,
  "write_mode": "read-only-api-v0"
}
```

## Bitte auf dem iPhone

Öffnen:

```text
http://100.85.29.67:5173
```

Falls noch die alte Fehlermeldung erscheint: bitte einmal **Hard Refresh** oder neuer privater Tab. Der Browser kann den alten Vite-Client hartnäckig cachen — kleine Frechheit, aber technisch legal.

## Korrekte Startbefehle für später

Backend:

```bash
cd /home/agent/.hermes/repos/FinanceManager
source ~/jarvis_runtime/finance-system/venv/bin/activate
PYTHONPATH=src uvicorn jarvis_finance.api.main:app --host 127.0.0.1 --port 8000
```

Frontend:

```bash
cd /home/agent/.hermes/repos/FinanceManager/frontend
VITE_API_BASE_URL=http://100.85.29.67:5173 \
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=100.85.29.67,agent.tailbd371b.ts.net \
npm run dev -- --host 0.0.0.0 --port 5173
```

Wichtig: `npm install` ist dafür **nicht** Teil des Startbefehls, solange `node_modules` vorhanden ist.


finance-system-architecture

# Vue dashboard over Tailscale: API base and proxy

When restarting or troubleshooting the FinanceManager Vue dashboard for remote/Tailscale use, verify the browser-visible API base, not only localhost health checks.

## Symptom

The page loads via a Tailscale URL, but the UI reports:

- `Backend nicht erreichbar`
- `Runtime-DB nicht verfügbar`

Meanwhile server-side checks may still show:

- `http://127.0.0.1:8000/api/runtime/status` works
- `http://127.0.0.1:5173/api/runtime/status` works

## Root cause

If `VITE_API_BASE_URL` is absent, the frontend client can fall back to `http://127.0.0.1:8000`. In a browser opened on the user's laptop/phone, `127.0.0.1` points to the user's device, not the FinanceManager host. The UI then cannot reach FastAPI even though the backend is healthy on the server.

For Tailscale access, prefer routing API calls through the same Vite origin/proxy that served the page:

```bash
VITE_API_BASE_URL=http://<tailscale-host>:5173
```

Example:

```bash
VITE_API_BASE_URL=http://agent.tailbd371b.ts.net:5173 \
  __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=agent.tailbd371b.ts.net \
  npm run dev -- --host 0.0.0.0 --port 5173
```

Using the same origin avoids remote-browser localhost confusion and CORS/origin surprises.

## Restart checklist

Do not report "Backend/Frontend neu gestartet" from process-start output alone. For this user's iPhone/Tailscale workflow, restart is only complete after stale listeners are killed, new listeners are observed on the expected bind addresses, and `/api/runtime/status` succeeds through the browser-visible Tailscale/Vite origin.

1. Stop existing dashboard processes and stale listeners on ports `8000` and `5173`.
2. Confirm both ports are free before starting new processes:

```bash
ss -ltnp '( sport = :8000 or sport = :5173 )' || true
```

3. Start FastAPI bound to localhost only:

```bash
cd /home/agent/.hermes/repos/FinanceManager
source ~/jarvis_runtime/finance-system/venv/bin/activate
PYTHONPATH=src uvicorn jarvis_finance.api.main:app --host 127.0.0.1 --port 8000
```

3. Resolve Tailscale DNS/IP:

```bash
tailscale status --json | python -c 'import json,sys; d=json.load(sys.stdin); print(d.get("Self",{}).get("DNSName","").rstrip("."))'
tailscale ip -4 | head -n1
```

5. Start Vite with a browser-visible API base. If `node_modules` already exists, do **not** include `npm install` in the normal start command; install is a separate maintenance step, not part of every restart.

```bash
cd /home/agent/.hermes/repos/FinanceManager/frontend
VITE_API_BASE_URL=http://<tailscale-host>:5173 \
  __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=<tailscale-host> \
  npm run dev -- --host 0.0.0.0 --port 5173
```

For the current Tailscale IP style, this is the practical form:

```bash
cd /home/agent/.hermes/repos/FinanceManager/frontend
VITE_API_BASE_URL=http://100.85.29.67:5173 \
  __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=100.85.29.67,agent.tailbd371b.ts.net \
  npm run dev -- --host 0.0.0.0 --port 5173
```

6. Verify listeners and note that background-process parent PIDs can differ from the final child listener PIDs; trust `ss`, not the initial process-start line:

```bash
ss -ltnp '( sport = :8000 or sport = :5173 )'
```

Expected shape:

- `127.0.0.1:8000` handled by `uvicorn`
- `0.0.0.0:5173` handled by `node`

7. Verify all of these, not just localhost:

```bash
curl -fsS http://127.0.0.1:8000/api/runtime/status
curl -fsS http://127.0.0.1:5173/api/runtime/status
curl -fsS http://<tailscale-host>:5173/api/runtime/status
curl -fsS http://<tailscale-host>:5173/api/health
curl -fsS http://<tailscale-host>:5173/src/api/client.ts | grep -o "http://[^']*:5173" | head -1
```

Expected runtime status contains:

```json
{"db_available":true,"runtime_outside_repo":true,"write_mode":"read-only-api-v0"}
```

8. Verify the delivered dev client contains the browser-visible API base, because the page can load while still calling `127.0.0.1` from the phone:

```bash
curl -fsS http://<tailscale-host>:5173/src/api/client.ts \
  | grep -E "VITE_API_BASE_URL|127\.0\.0\.1|localhost|http://[^']*:5173" \
  | head -20
```

Expected: `import.meta.env` includes `VITE_API_BASE_URL: "http://<tailscale-host>:5173"`. A fallback constant such as `DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"` is acceptable only if `VITE_API_BASE_URL` is actually set.

9. Browser-sanity the Tailscale URL and inspect the console. The Command Center must say:

- `FastAPI verbunden: ja`
- `Runtime-DB verfügbar: ja`
- `Externe Provider beim Rendern: nein`

10. If the user still sees the old error after the fix, ask for a hard refresh/private tab before changing backend code.

## Durable implementation note

The repo's `scripts/start_vue_dashboard.sh` should set `VITE_API_BASE_URL` automatically from Tailscale DNS/IP when available, and export `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` for the Tailscale host. Keep the UI fallback instructions aligned with this command so users do not copy a localhost-only frontend start recipe.


Explanation:
**Fixing cleanup and numbering**

I'm considering the updates I've already made. I think I need to add the fuser hard cleanup and possibly fix the numbering, as it might still be from previous edits. Oh, and I have this unique line that needs a patch: "1. Stop existing..." — I’ll need to add that extra command. It seems like there are some adjustments to make, but I'm already on it!

1. Stop existing dashboard processes and stale listeners on ports `8000` and `5173`.
2. Confirm both ports are free before starting new processes:

```bash
ss -ltnp '( sport = :8000 or sport = :5173 )' || true
```

1. Stop existing dashboard processes and stale listeners on ports `8000` and `5173`. Use port-based cleanup so stale Vite/uvicorn children cannot keep serving an old client:

```bash
for port in 8000 5173; do
  pids=$(fuser -n tcp "$port" 2>/dev/null || true)
  [ -n "$pids" ] && kill $pids 2>/dev/null || true
done
sleep 1
for port in 8000 5173; do
  pids=$(fuser -n tcp "$port" 2>/dev/null || true)
  [ -n "$pids" ] && kill -9 $pids 2>/dev/null || true
done
```

2. Confirm both ports are free before starting new processes:

```bash
ss -ltnp '( sport = :8000 or sport = :5173 )' || true
```


Patched references/vue-dashboard-tailscale-api-base.md in skill 'finance-system-architecture' (1 replacement).


Erledigt, Sir.

Ich habe das Vorgehen dauerhaft im Skill **`finance-system-architecture`** ergänzt — konkret in:

`references/vue-dashboard-tailscale-api-base.md`

Gespeichert ist jetzt insbesondere:

- harte Port-Bereinigung für `8000` und `5173`
- `ss`-Verifikation der Listener
- Backend nur auf `127.0.0.1:8000`
- Frontend auf `0.0.0.0:5173`
- `VITE_API_BASE_URL=http://<tailscale-host>:5173`
- Tailscale-Checks für `/api/runtime/status`
- Prüfung, ob der ausgelieferte Vite-Client wirklich nicht vom iPhone aus `127.0.0.1` anspricht
- Hinweis: `npm install` gehört nicht in den normalen Restart, solange `node_modules` vorhanden ist

Beim nächsten FinanceManager/Tailscale-Dashboard-Restart werde ich dieses Verfahren verwenden, statt nur „Prozess gestartet, wird schon passen“ zu spielen — eine Strategie, die bekanntlich eher in die Kategorie Optimismus mit Stromanschluss fällt.


[M G] [IMPORTANT: Background process proc_4406c74f66c6 matched watch pattern "Application startup complete".
Command: cd /home/agent/.hermes/repos/FinanceManager && source ~/jarvis_runtime/finance-system/venv/bin/activate && PYTHONPATH=src uvicorn jarvis_finance.api.main:app --host 127.0.0.1 --port 8000
Matched output:
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)]


Zur Kenntnis genommen, Sir.

Das ist die erwartete Backend-Bestätigung:

- **FastAPI/Uvicorn läuft**
- Adresse: `http://127.0.0.1:8000`
- Startup: erfolgreich

Der bereits verifizierte iPhone/Tailscale-Zugriff bleibt:

```text
http://100.85.29.67:5173
```

Frontend routet die API aktuell über:

```text
http://100.85.29.67:5173/api/...
```

Damit sollte das Dashboard auf dem iPhone nicht mehr in die `127.0.0.1`-Falle laufen.


# Budget/Cashflow source-aware import review pattern

Use this when correcting Budget/Cashflow imports after CSV-derived review candidates are too granular or when multiple sources overlap (credit card, Migros/Cumulus, AKB/Raiffeisen, manual bookings).

## Scope and safety

- Keep this as an import-logic/review sprint, not a new analytics architecture.
- Runtime DB backup first; verify backup exists, is outside Git, and checksum passes.
- Report only aggregate counts/statuses; do not print real amounts, raw rows, source file contents, account IDs, or secrets.
- Real CSV/XLS/XLSX files and the runtime DB stay outside Git.
- Preserve Dry run → Review → Confirm. Do not confirm productive transactions unless explicitly approved.

## Source-of-truth precedence

1. **Migros CSV** is source of truth for Migros expenses.
   - Credit-card rows matching Migros/Migrol/Cumulus-style merchant text should become `covered_by_migros` or ignored for booking, not duplicate expenses.
   - Migros article rows may remain as internal detail data, but normal user review must show receipt/transaction candidates, not thousands of line items.
   - Group Migros receipts by stable receipt keys such as date, time, branch/store, register, and transaction number where available.
2. **Credit-card CSV** is source of truth for non-Migros card payments: subscriptions, online purchases, travel, restaurants, Galaxus, etc.
   - Safe subscription merchants such as Apple/Apple.com, Google, Amazon Prime, Netflix, Disney/DisneyPlus may auto-map to subscriptions if not duplicate.
   - Galaxus remains review-required and splittable; do not final-categorize it automatically.
3. **Bank CSVs (AKB/Raiffeisen)** are source of truth for salary/income, transfers, fixed costs, rent, insurance, reimbursements, taxes, and bank payments.
   - Internal own-account transfers between AKB/Raiffeisen must not count as income/expense. Mark as transfer candidates.
   - Income detection can create income candidates, but ambiguous bank rows remain in review.
4. **Manual bookings** handle missing transactions, corrections, cash payments, special cases, and non-imported items.

## Review UX/API requirements

- Normal review must not be flooded by detail/article rows.
- Provide tabs/filters for: all, auto-assigned, needs review, duplicates, transfers, Galaxus, Migros over-threshold, bank transfers, income, ignored/covered.
- Review actions should be audited: change category, create split, ignore, mark transfer, and batch-confirm only safe candidates.
- Auto decisions must store rule/classification/reason/source-priority metadata so the user can understand and reverse them.

## Runtime cleanup recipe for too-granular Migros imports

1. Inspect candidate tables aggregate-only to count open Migros-like rows and likely article-level rows.
2. Create and verify runtime DB backup.
3. Mark old article-level candidates `superseded` (or archive/delete only if the schema already supports it) with an audit/cleanup event.
4. Re-import Migros as receipt-level candidates; store article rows in a detail table keyed to the receipt candidate.
5. Re-run credit-card import with Migros-card rows marked `covered_by_migros`.
6. Verify productive transaction counts did not increase unless Confirm was explicitly approved.

## Test matrix

- Migros article rows aggregate into one receipt candidate.
- Migros article rows do not appear in normal review.
- Credit-card Migros rows become `covered_by_migros`.
- Apple/Google/Amazon Prime/Netflix/Disney variants map to subscription candidates.
- Galaxus stays review-required and splittable.
- Duplicate candidates are recognized before safe auto-confirm.
- Bank salary/income rows become income candidates.
- AKB/Raiffeisen internal transfers become transfer candidates, not income/expense.
- Review actions are audited: category change, split, ignore, transfer mark, batch confirm.
- Safety checks block real CSV/XLS/DB artifacts from Git and scan source/build artifacts for secrets.

## Completion report shape

Include yes/no for: Migros article cleanup, Migros receipt aggregation, credit-card rules, AKB/Raiffeisen profiles, transfer recognition, income recognition, review UX, recategorization, MVP-light rule engine, productive import. Include counts by source/classification, test/build results, Git-safety, commit hash, push status, and open decisions — without amounts or raw rows.


# Budget Phase 1.11 — Status categories, Income page, and formatting

Use this reference when a FinanceManager Budget/Cashflow sprint focuses on **Budgetstatus correctness, Income/Einkünfte UX, and dashboard formatting**, not import engines.

## Scope boundaries

- Do **not** add VISA/Migros/Cumulus imports, OCR, rule engines, or duplicate engines in this class of sprint unless explicitly re-authorized.
- Keep real budget files, runtime SQLite DBs, secrets, build artifacts, and frontend env secrets out of Git.
- Before runtime checks or cleanup, create a runtime DB backup outside the repo and report only aggregate counts/no real amounts.

## Budgetstatus category logic

Budgetstatus by category must not be an inner join on budget plans. Use a Full-Outer-Join-style result over:

1. active budget categories with active budget plans for the active year,
2. active categories with confirmed budget transactions for the active year,
3. categories with both plan and actuals,
4. categories with actuals but no plan.

Acceptance rules:

- Actual-only categories appear in the table.
- Plan-only categories appear with neutral/no-actuals status.
- Empty categories without plan and without actuals do not appear.
- Actual-only expense categories get status `ohne Budget` plus a hint like `Ausgaben vorhanden, aber kein Budgetplan`.
- Income and expense status semantics differ:
  - income: green when actual/prognosis meets or exceeds plan; yellow/red when below plan,
  - expenses: green when forecast stays under budget; yellow near budget; red/critical over budget.

## Parent/subcategory rollups

Transactions posted to subcategories must not disappear. For MVP:

- include the booked subcategory row directly,
- also aggregate actuals into active ancestors/parent categories for rollup visibility,
- expose simple rollup metadata such as `is_rollup` and child-count if useful for UI/drilldown.

Avoid silently moving transactions between categories just to make the status table look right.

## Decimal formatting pattern

For Vue/FastAPI dashboard user-facing values:

- create central frontend formatters such as `formatMoney`, `formatPercent`, `formatQuantity`, `formatMaybeMoney`,
- display money as e.g. `CHF 4’019.31`, `CHF 0.00`, and no scientific notation,
- display percentages with two decimals, e.g. `82.45 %`,
- use formatting only at DTO/UI boundaries; keep DB values as Decimal/TEXT and do not introduce float arithmetic in domain logic,
- update tests when output contracts intentionally move from `0` to `0.00`.

Regression test a long decimal such as `4019.308333333333482 -> CHF 4’019.31`.

## Einkünfte / Income page MVP

Prefer existing `budget_plan_items` with `category_type='income'` over a parallel income model.

Minimum MVP actions:

- list income plans,
- add income plan with audit,
- archive income plan with audit,
- show Excel/seed income candidates as review items,
- show summary cards for planned monthly/yearly income, actual current month, actual YTD, deviation, open review.

Model mapping tips:

- monthly amount → yearly = monthly × 12,
- annual amount → monthly average = annual ÷ 12,
- one-time can remain explicit/one-time depending on existing cadence semantics,
- store source/person/type metadata in structured columns if available; notes parsing is acceptable as an MVP bridge only.

Excel income candidate reporting:

- aggregate-only: count recognized, clear, review-required,
- never print amounts or raw source rows in chat,
- keep Review/Confirm as the productive write boundary.

## Runtime and verification pattern

- Runtime backup before checks/mutations.
- Runtime check for named categories should report counts/statuses only, not real amounts.
- A category not appearing after the fix can be valid if there are zero confirmed active-year transactions and no plan; report that distinction clearly.
- Full gate: Python compile, full pytest, frontend vitest, frontend build, `git diff --check`, source secret scan, frontend build secret scan, Git-safety for runtime artifacts, commit, push, remote hash equality.
- Restart with `./scripts/stop_vue_dashboard.sh` and `./scripts/start_vue_dashboard.sh`; if a stale Vite/node process still owns port 5173, identify and terminate only that dashboard process, then restart.
- Verify local backend, local frontend, and Tailscale frontend URL (`tailscale ip -4`) before reporting links. If `tailscale serve status` has no config, report IP URL only plus local URLs.


# Budget Phase 1.10 — 2026 core cleanup pattern

Use this reference when the user asks to make the Budget/Cashflow module usable for a specific active budget year after import-review/category/workflow drift.

## Scope boundaries

- This is a core-correctness sprint, not a new importer or analytics expansion.
- No new import sources.
- No real CSV/XLS/XLSX/SQLite/runtime DB files in Git.
- Do not expose real amounts/IDs in chat; report counts/statuses only.
- Before any runtime mutation, create an external runtime backup and report the backup path only.
- Do not mass-confirm real candidates during implementation unless the user gives explicit current-turn approval.

## Active-year enforcement

For active budget year 2026:

- Default every budget user-facing query to `year=2026` / `budget_year=2026`:
  - Import Review candidates
  - Effective Expenses
  - Effective Income
  - Budgetstatus
  - Charts / cashflow summaries
- Keep UI year-selectable, but default to 2026.
- Hide 2025 candidates from default active review.
- Mark 2025 candidates as `reference_2025` or `archived_reference` when they are historical/planning references.
- Block confirm when `candidate.transaction_date[:4] != budget_year`.
- Include `budget_year` in single Confirm, Batch Preview, and Batch Confirm payloads.
- Make Review Summary year-aware too; otherwise the table can look fixed while summary cards still count old-year rows.

## Category cleanup pattern

Simplify category display around logical buckets derived from `category_type`; buckets do not need to be real categories.

Canonical income leaves:

- Lohn Marcel
- Lohn Melanie
- Gratifikation
- Sonstige Einnahmen
- Rückerstattungen

Canonical expense leaves:

- Essen & Haushalt
- Wohnen
- Versicherungen
- Gesundheit / Medizin
- Mobilität / Auto / Transport
- Kinder / Familie
- Freizeit / Abos / Ausflüge
- Hausrat / Möbel & Garten
- Shopping / Kleidung & Elektronik
- Haustiere
- Steuern / Behörden
- Sonstiges / Administration
- Review nötig

Cleanup service should:

- ensure canonical leaves exist and are active;
- flatten unnecessary parent categories for Budget UI purposes;
- merge `Sonstiges` into `Sonstiges / Administration`;
- archive/deactivate UAT/test/dummy categories rather than hard-deleting;
- move transactions/plans/candidates before archiving a non-empty category;
- write audit events for every merge/archive/ensure action.

Pitfall: `confirm_create_category` may normalize category_type unexpectedly for older IDs/categories. For canonical ensured categories, check by canonical ID first and insert/update directly if needed, with audit.

## Bulk review and confirm pattern

Import Review must support:

- checkbox per candidate;
- Select All for current filtered view;
- visible selected count;
- bulk category dropdown;
- category assignment;
- Preview-gated Batch Confirm;
- bulk ignore/reopen/transfer/covered actions;
- per-candidate errors surfaced in the UI.

Backend batch function should return counters plus `errors: [{candidate_id, error}]`. Do not silently skip. For category assignment, update `proposed_category_id/proposed_category_name`, clear review when appropriate, refresh UI, and audit each candidate or the batch with details.

## Budgetstatus/monthly matrix pattern

Budgetstatus rows should be by category and include:

- Budget Monat
- Budget Jahr
- Ist Jahr bisher
- monthly_actuals for `01` through `12`
- Ist Ø Monat
- Prognose Jahr
- Abweichung Jahr
- Status/Ampel

Actuals must come only from `budget_transactions.status='confirmed'`, the selected year, and relevant transaction types. Candidates never count as actuals.

Forecast:

- monthly plan: `budget_year = monthly * 12`
- annual plan: `budget_month = annual / 12`
- `actual_avg_month = actual_year_to_date / denominator`
- `forecast_year = actual_avg_month * 12`

Ampel:

- Expenses: Grün `<=90%`, Gelb `90–100%`, Rot `>100%`, Kritisch `>115%`.
- Income: Grün `>=100%`, Gelb `90–100%`, Rot `<90%`.
- With no actuals or no budget, show neutral/empty instead of fake precision.

Charts can be MVP-light:

- income/expense/net by month;
- budget vs actual/forecast by category;
- forecast overrun list;
- clear empty state when no confirmed rows exist.

## Runtime cleanup/verifications

After code/tests pass and before runtime mutations:

1. Copy runtime DB to external backup path.
2. Apply migrations.
3. Run category cleanup.
4. Archive 2025 reference candidates.
5. Archive/deactivate UAT/test/dummy/synthetic/dry-run rows from user views with audit; do not delete.
6. Verify:
   - open 2025 candidates in default review = 0;
   - bulk confirms executed = 0 unless explicitly approved;
   - default user views query 2026 only;
   - schema version expected by tests matches migration version.

## Verification gates

- `python -m compileall src tests`
- full `pytest -q`
- frontend `npm test -- --run`
- frontend `npm run build`
- `git diff --check`
- changed-file source secret scan
- frontend build secret scan using real secret-shaped patterns, not broad words like “secret” in UI copy
- changed/untracked data-artifact scan for `.csv/.xls/.xlsx/.sqlite/.sqlite3/.db`
- commit, push, and verify remote branch hash through authenticated URL when normal origin hash lookup cannot authenticate

Pitfall: Full Git scan may find already tracked synthetic fixtures. Distinguish pre-existing tracked fixtures from newly changed/untracked real data artifacts; do not stage new data files.


test-driven-development

---
name: test-driven-development
description: "TDD: enforce RED-GREEN-REFACTOR, tests before code."
version: 1.1.0
author: Hermes Agent (adapted from obra/superpowers)
license: MIT
platforms: [linux, macos, windows]
metadata:
  hermes:
    tags: [testing, tdd, development, quality, red-green-refactor]
    related_skills: [systematic-debugging, writing-plans, subagent-driven-development]
---

# Test-Driven Development (TDD)

## Overview

Write the test first. Watch it fail. Write minimal code to pass.

**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing.

**Violating the letter of the rules is violating the spirit of the rules.**

## When to Use

**Always:**
- New features
- Bug fixes
- Refactoring
- Behavior changes

**Exceptions (ask the user first):**
- Throwaway prototypes
- Generated code
- Configuration files

Thinking "skip TDD just this once"? Stop. That's rationalization.

## The Iron Law

```
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
```

Write code before the test? Delete it. Start over.

**No exceptions:**
- Don't keep it as "reference"
- Don't "adapt" it while writing tests
- Don't look at it
- Delete means delete

Implement fresh from tests. Period.

## Red-Green-Refactor Cycle

### RED — Write Failing Test

Write one minimal test showing what should happen.

**Good test:**
```python
def test_retries_failed_operations_3_times():
    attempts = 0
    def operation():
        nonlocal attempts
        attempts += 1
        if attempts < 3:
            raise Exception('fail')
        return 'success'

    result = retry_operation(operation)

    assert result == 'success'
    assert attempts == 3
```
Clear name, tests real behavior, one thing.

**Bad test:**
```python
def test_retry_works():
    mock = MagicMock()
    mock.side_effect = [Exception(), Exception(), 'success']
    result = retry_operation(mock)
    assert result == 'success'  # What about retry count? Timing?
```
Vague name, tests mock not real code.

**Requirements:**
- One behavior per test
- Clear descriptive name ("and" in name? Split it)
- Real code, not mocks (unless truly unavoidable)
- Name describes behavior, not implementation

### Verify RED — Watch It Fail

**MANDATORY. Never skip.**

```bash
# Use terminal tool to run the specific test
pytest tests/test_feature.py::test_specific_behavior -v
```

Confirm:
- Test fails (not errors from typos)
- Failure message is expected
- Fails because the feature is missing

**Test passes immediately?** You're testing existing behavior. Fix the test.

**Test errors?** Fix the error, re-run until it fails correctly.

### GREEN — Minimal Code

Write the simplest code to pass the test. Nothing more.

**Good:**
```python
def add(a, b):
    return a + b  # Nothing extra
```

**Bad:**
```python
def add(a, b):
    result = a + b
    logging.info(f"Adding {a} + {b} = {result}")  # Extra!
    return result
```

Don't add features, refactor other code, or "improve" beyond the test.

**Cheating is OK in GREEN:**
- Hardcode return values
- Copy-paste
- Duplicate code
- Skip edge cases

We'll fix it in REFACTOR.

### Verify GREEN — Watch It Pass

**MANDATORY.**

```bash
# Run the specific test
pytest tests/test_feature.py::test_specific_behavior -v

# Then run ALL tests to check for regressions
pytest tests/ -q
```

Confirm:
- Test passes
- Other tests still pass
- Output pristine (no errors, warnings)

**Test fails?** Fix the code, not the test.

**Other tests fail?** Fix regressions now.

### REFACTOR — Clean Up

After green only:
- Remove duplication
- Improve names
- Extract helpers
- Simplify expressions

Keep tests green throughout. Don't add behavior.

**If tests fail during refactor:** Undo immediately. Take smaller steps.

### Repeat

Next failing test for next behavior. One cycle at a time.

## Why Order Matters

**"I'll write tests after to verify it works"**

Tests written after code pass immediately. Passing immediately proves nothing:
- Might test the wrong thing
- Might test implementation, not behavior
- Might miss edge cases you forgot
- You never saw it catch the bug

Test-first forces you to see the test fail, proving it actually tests something.

**"I already manually tested all the edge cases"**

Manual testing is ad-hoc. You think you tested everything but:
- No record of what you tested
- Can't re-run when code changes
- Easy to forget cases under pressure
- "It worked when I tried it" ≠ comprehensive

Automated tests are systematic. They run the same way every time.

**"Deleting X hours of work is wasteful"**

Sunk cost fallacy. The time is already gone. Your choice now:
- Delete and rewrite with TDD (high confidence)
- Keep it and add tests after (low confidence, likely bugs)

The "waste" is keeping code you can't trust.

**"TDD is dogmatic, being pragmatic means adapting"**

TDD IS pragmatic:
- Finds bugs before commit (faster than debugging after)
- Prevents regressions (tests catch breaks immediately)
- Documents behavior (tests show how to use code)
- Enables refactoring (change freely, tests catch breaks)

"Pragmatic" shortcuts = debugging in production = slower.

**"Tests after achieve the same goals — it's spirit not ritual"**

No. Tests-after answer "What does this do?" Tests-first answer "What should this do?"

Tests-after are biased by your implementation. You test what you built, not what's required. Tests-first force edge case discovery before implementing.

## Common Rationalizations

| Excuse | Reality |
|--------|---------|
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
| "I'll test after" | Tests passing immediately prove nothing. |
| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" |
| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. |
| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. |
| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. |
| "Need to explore first" | Fine. Throw away exploration, start with TDD. |
| "Test hard = design unclear" | Listen to the test. Hard to test = hard to use. |
| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. |
| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. |
| "Existing code has no tests" | You're improving it. Add tests for the code you touch. |

## Red Flags — STOP and Start Over

If you catch yourself doing any of these, delete the code and restart with TDD:

- Code before test
- Test after implementation
- Test passes immediately on first run
- Can't explain why test failed
- Tests added "later"
- Rationalizing "just this once"
- "I already manually tested it"
- "Tests after achieve the same purpose"
- "Keep as reference" or "adapt existing code"
- "Already spent X hours, deleting is wasteful"
- "TDD is dogmatic, I'm being pragmatic"
- "This is different because..."

**All of these mean: Delete code. Start over with TDD.**

## Verification Checklist

Before marking work complete:

- [ ] Every new function/method has a test
- [ ] Watched each test fail before implementing
- [ ] Each test failed for expected reason (feature missing, not typo)
- [ ] Wrote minimal code to pass each test
- [ ] All tests pass
- [ ] Output pristine (no errors, warnings)
- [ ] Tests use real code (mocks only if unavoidable)
- [ ] Edge cases and errors covered

Can't check all boxes? You skipped TDD. Start over.

## When Stuck

| Problem | Solution |
|---------|----------|
| Don't know how to test | Write the wished-for API. Write the assertion first. Ask the user. |
| Test too complicated | Design too complicated. Simplify the interface. |
| Must mock everything | Code too coupled. Use dependency injection. |
| Test setup huge | Extract helpers. Still complex? Simplify the design. |

## Hermes Agent Integration

### Running Tests

Use the `terminal` tool to run tests at each step:

```python
# RED — verify failure
terminal("pytest tests/test_feature.py::test_name -v")

# GREEN — verify pass
terminal("pytest tests/test_feature.py::test_name -v")

# Full suite — verify no regressions
terminal("pytest tests/ -q")
```

### With delegate_task

When dispatching subagents for implementation, enforce TDD in the goal:

```python
delegate_task(
    goal="Implement [feature] using strict TDD",
    context="""
    Follow test-driven-development skill:
    1. Write failing test FIRST
    2. Run test to verify it fails
    3. Write minimal code to pass
    4. Run test to verify it passes
    5. Refactor if needed
    6. Commit

    Project test command: pytest tests/ -q
    Project structure: [describe relevant files]
    """,
    toolsets=['terminal', 'file']
)
```

### With systematic-debugging

Bug found? Write failing test reproducing it. Follow TDD cycle. The test proves the fix and prevents regression.

Never fix bugs without a test.

## Testing Anti-Patterns

- **Testing mock behavior instead of real behavior** — mocks should verify interactions, not replace the system under test
- **Testing implementation details** — test behavior/results, not internal method calls
- **Happy path only** — always test edge cases, errors, and boundaries
- **Brittle tests** — tests should verify behavior, not structure; refactoring shouldn't break them

## Final Rule

```
Production code → test exists and failed first
Otherwise → not TDD
```

No exceptions without the user's explicit permission.


Verstanden, Sir. Ich behandle das als **autorisierten, gezielten Budget Phase 1.11 Sprint**: Migros-Receipt-Autobooking für 2026 plus Monatsanalyse/Drilldown im Budgetstatus. Keine neue Importquelle, keine OCR/Rule-Engine-Grossbaustelle, keine Beträge im Chat. Ich beginne mit Code-/Schema-Inventar, schreibe RED-Tests, erstelle vor Runtime-Mutation ein Backup und berichte nur Counts/Status.

todo

Arbeitsbasis prüfen: Git-Status, Budget/Migros/API/Vue-Struktur, laufende Prozesse inventarisieren

RED-Tests Backend Migros-Regel/Confirm/Audit/no-articles/no-CC-duplicate und Budgetstatus Monatsanalytics schreiben

RED-Tests Frontend Budgetstatus Row Click, Monatschart, Drilldown, Link Effektive Ausgaben schreiben

Backend implementieren: Migros 2026 Dry-Run/Autobook Service, Audit, receipt-level details, monthly analytics DTO/API

Frontend implementieren: Budgetstatus Monatsdiagramm, Drilldown, Donut, Detailtabelle, Ausgaben-Link; Migros Drawer Details

Runtime Backup + Dry Run + autorisierte produktive Migros-Receipt-Buchung 2026 aggregate-only ausführen und Audit verifizieren

Verification gates: compile, pytest, npm test/build, scans, Git-safety, diff-check

Commit, Push, Remote-Hash, Dashboard Restart/Tailscale Sanity, Abschlussbericht


terminal

git status --short --branch && git rev-parse --show-toplevel && git branch --show-current


Output:
## feat/budget-phase-1-4...origin/feat/budget-phase-1-4 [voraus 3]
/home/agent/.hermes/repos/FinanceManager
feat/budget-phase-1-4


     1|from __future__ import annotations
     2|
     3|from fastapi import APIRouter, Depends
     4|from sqlite3 import Connection
     5|
     6|from jarvis_finance.api.dependencies import get_db
     7|from jarvis_finance.services.budget_accounts import archive_budget_account, confirm_create_budget_account, create_budget_account_preview, list_budget_accounts
     8|from jarvis_finance.services.budget_categories import (
     9|    archive_category,
    10|    archive_tag,
    11|    confirm_category_merge,
    12|    confirm_create_category,
    13|    confirm_create_tag,
    14|    create_category_preview,
    15|    create_tag_preview,
    16|    delete_empty_category,
    17|    list_category_tree_grouped,
    18|    list_categories,
    19|    list_tags,
    20|    preview_category_merges,
    21|    update_category,
    22|)
    23|from jarvis_finance.services.budget_imports import (
    24|    archive_reference_year_candidates,
    25|    apply_budget_rules_to_open_candidates,
    26|    batch_confirm_safe_candidates,
    27|    batch_mark_candidates,
    28|    bulk_update_transaction_candidate_category,
    29|    confirm_candidate_split,
    30|    confirm_transaction_candidate,
    31|    create_budget_rule,
    32|    create_candidate_split,
    33|    create_review_category_for_candidate,
    34|    deactivate_budget_rule,
    35|    ignore_transaction_candidate,
    36|    list_budget_rules,
    37|    list_transaction_candidates,
    38|    mark_candidate_transfer,
    39|    preview_batch_confirm_candidates,
    40|    preview_safe_auto_candidates,
    41|    preview_transaction_candidate,
    42|    reopen_transaction_candidate,
    43|    review_candidate_summary,
    44|    seed_bank_transfer_candidates_from_rows,
    45|    seed_credit_card_candidates_from_rows,
    46|    seed_migros_candidates_from_rows,
    47|    seed_transaction_candidates_from_csv_text,
    48|    seed_transaction_candidates_from_rows,
    49|    test_budget_rule,
    50|    update_transaction_candidate_category,
    51|)
    52|from jarvis_finance.services.budget_overview import get_budget_charts_2026, get_budget_overview, get_budget_status_by_category, get_category_summary, get_income_status, get_monthly_summary
    53|from jarvis_finance.services.budget_plans import archive_budget_plan_item, confirm_budget_plan_item, create_budget_plan_item_preview, list_budget_plan_items, list_income_plan_items, list_income_seed_candidates
    54|from jarvis_finance.services.budget_seed_review import (
    55|    confirm_budget_plan_takeover,
    56|    confirm_clear_category_takeover,
    57|    confirm_seed_candidate,
    58|    convert_unclean_range,
    59|    ignore_seed_candidate,
    60|    list_seed_candidates,
    61|    preview_budget_plan_takeover,
    62|    preview_clear_category_takeover,
    63|    preview_seed_candidate,
    64|    reopen_seed_candidate,
    65|    seed_budget_candidates_from_rows,
    66|    set_seed_candidate_status,
    67|)
    68|from jarvis_finance.services.budget_transactions import (
    69|    confirm_budget_transaction,
    70|    confirm_reverse_budget_transaction,
    71|    confirm_transfer,
    72|    create_budget_transaction_preview,
    73|    create_transfer_preview,
    74|    list_budget_transactions,
    75|    reverse_budget_transaction_preview,
    76|    update_budget_transaction,
    77|)
    78|
    79|router = APIRouter(prefix="/budget", tags=["budget"])
    80|
    81|
    82|@router.get("/overview")
    83|def overview(month: str | None = None, conn: Connection = Depends(get_db)):
    84|    return get_budget_overview(conn, month)
    85|
    86|
    87|@router.get("/status/categories")
    88|def category_budget_status(year: str = "2026", conn: Connection = Depends(get_db)):
    89|    return get_budget_status_by_category(conn, year=year)
    90|
    91|
    92|@router.get("/charts")
    93|def budget_charts(year: str = "2026", conn: Connection = Depends(get_db)):
    94|    return get_budget_charts_2026(conn, year=year)
    95|
    96|
    97|@router.get("/income/status")
    98|def income_budget_status(year: str = "2026", conn: Connection = Depends(get_db)):
    99|    return get_income_status(conn, year=year)
   100|
   101|
   102|@router.get("/income/plans")
   103|def income_plans(year: str = "2026", include_archived: bool = False, conn: Connection = Depends(get_db)):
   104|    return list_income_plan_items(conn, year=year, include_archived=include_archived)
   105|
   106|
   107|@router.get("/income/seed-candidates")
   108|def income_seed_candidates(year: str = "2026", conn: Connection = Depends(get_db)):
   109|    return list_income_seed_candidates(conn, year=year)
   110|
   111|
   112|@router.post("/income/plans/preview")
   113|def income_plans_preview(payload: dict, conn: Connection = Depends(get_db)):
   114|    return create_budget_plan_item_preview(conn, payload)
   115|
   116|
   117|@router.post("/income/plans/confirm")
   118|def income_plans_confirm(payload: dict, conn: Connection = Depends(get_db)):
   119|    return confirm_budget_plan_item(conn, payload)
   120|
   121|
   122|@router.post("/income/plans/{plan_item_id}/archive")
   123|def income_plans_archive(plan_item_id: str, conn: Connection = Depends(get_db)):
   124|    return archive_budget_plan_item(conn, plan_item_id)
   125|
   126|
   127|@router.get("/monthly-summary")
   128|def monthly_summary(month: str | None = None, conn: Connection = Depends(get_db)):
   129|    return get_monthly_summary(conn, month)
   130|
   131|
   132|@router.get("/categories/summary")
   133|def categories_summary(month: str | None = None, conn: Connection = Depends(get_db)):
   134|    return get_category_summary(conn, month)
   135|
   136|
   137|@router.get("/accounts")
   138|def accounts(include_archived: bool = False, conn: Connection = Depends(get_db)):
   139|    return list_budget_accounts(conn, include_archived=include_archived)
   140|
   141|
   142|@router.post("/accounts/preview")
   143|def accounts_preview(payload: dict, conn: Connection = Depends(get_db)):
   144|    return create_budget_account_preview(conn, payload)
   145|
   146|
   147|@router.post("/accounts/confirm")
   148|def accounts_confirm(payload: dict, conn: Connection = Depends(get_db)):
   149|    return confirm_create_budget_account(conn, payload)
   150|
   151|
   152|@router.post("/accounts/{account_id}/archive")
   153|def accounts_archive(account_id: str, conn: Connection = Depends(get_db)):
   154|    return archive_budget_account(conn, account_id)
   155|
   156|
   157|@router.get("/categories")
   158|def categories(include_archived: bool = False, grouped: bool = False, conn: Connection = Depends(get_db)):
   159|    if grouped:
   160|        return list_category_tree_grouped(conn, include_archived=include_archived)
   161|    return list_categories(conn, include_archived=include_archived)
   162|
   163|
   164|@router.get("/categories/tree")
   165|def category_tree(include_archived: bool = False, conn: Connection = Depends(get_db)):
   166|    return list_category_tree_grouped(conn, include_archived=include_archived)
   167|
   168|
   169|@router.get("/tags")
   170|def tags(include_archived: bool = False, conn: Connection = Depends(get_db)):
   171|    return list_tags(conn, include_archived=include_archived)
   172|
   173|
   174|@router.post("/tags/preview")
   175|def tags_preview(payload: dict, conn: Connection = Depends(get_db)):
   176|    return create_tag_preview(conn, payload)
   177|
   178|
   179|@router.post("/tags/confirm")
   180|def tags_confirm(payload: dict, conn: Connection = Depends(get_db)):
   181|    return confirm_create_tag(conn, payload)
   182|
   183|
   184|@router.post("/tags/{tag_id}/archive")
   185|def tags_archive(tag_id: str, conn: Connection = Depends(get_db)):
   186|    return archive_tag(conn, tag_id)
   187|
   188|
   189|@router.get("/plans")
   190|def plans(month: str | None = None, include_archived: bool = True, conn: Connection = Depends(get_db)):
   191|    return list_budget_plan_items(conn, month=month, include_archived=include_archived)
   192|
   193|
   194|@router.post("/plans/preview")
   195|def plans_preview(payload: dict, conn: Connection = Depends(get_db)):
   196|    return create_budget_plan_item_preview(conn, payload)
   197|
   198|
   199|@router.post("/plans/confirm")
   200|def plans_confirm(payload: dict, conn: Connection = Depends(get_db)):
   201|    return confirm_budget_plan_item(conn, payload)
   202|
   203|
   204|@router.get("/seed-candidates")
   205|def seed_candidates(status: str | None = None, candidate_type: str | None = None, conn: Connection = Depends(get_db)):
   206|    return list_seed_candidates(conn, status=status, candidate_type=candidate_type)
   207|
   208|
   209|@router.get("/transaction-candidates")
   210|def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
   211|    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)
   212|
   213|
   214|@router.get("/transaction-candidates/summary")
   215|def transaction_candidates_summary(budget_year: str = "2026", conn: Connection = Depends(get_db)):
   216|    return review_candidate_summary(conn, budget_year=budget_year)
   217|
   218|
   219|@router.post("/transaction-candidates/generate")
   220|def transaction_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   221|    source_type = payload.get("source_type") or "csv_seed"
   222|    rows = payload.get("rows") or []
   223|    if source_type == "credit_card_csv":
   224|        return seed_credit_card_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Credit card CSV", subscription_category_id=payload.get("subscription_category_id"), migros_covered=payload.get("migros_covered", True))
   225|    if source_type == "migros_csv":
   226|        return seed_migros_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Migros CSV", auto_category_id=payload.get("auto_category_id"), threshold_chf=str(payload.get("threshold_chf") or "50.00"))
   227|    if source_type == "bank_csv":
   228|        return seed_bank_transfer_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Bank CSV", income_category_id=payload.get("income_category_id"))
   229|    if payload.get("csv_text"):
   230|        return seed_transaction_candidates_from_csv_text(conn, payload["csv_text"], source_file_label=payload.get("source_file_label") or "CSV Import", source_type=source_type)
   231|    return seed_transaction_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Budget Import", source_type=source_type)
   232|
   233|
   234|@router.post("/transaction-candidates/{candidate_id}/preview")
   235|def transaction_candidates_preview(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   236|    return preview_transaction_candidate(conn, candidate_id, payload or {})
   237|
   238|
   239|@router.post("/transaction-candidates/{candidate_id}/confirm")
   240|def transaction_candidates_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   241|    return confirm_transaction_candidate(conn, candidate_id, payload)
   242|
   243|
   244|@router.post("/transaction-candidates/{candidate_id}/ignore")
   245|def transaction_candidates_ignore(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   246|    return ignore_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   247|
   248|
   249|@router.post("/transaction-candidates/{candidate_id}/reopen")
   250|def transaction_candidates_reopen(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   251|    return reopen_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   252|
   253|
   254|@router.post("/transaction-candidates/{candidate_id}/category")
   255|def transaction_candidates_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   256|    return update_transaction_candidate_category(conn, candidate_id, payload.get("category_id"), payload.get("note"))
   257|
   258|
   259|@router.post("/transaction-candidates/bulk-category")
   260|def transaction_candidates_bulk_category(payload: dict, conn: Connection = Depends(get_db)):
   261|

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


     1|from __future__ import annotations
     2|
     3|from datetime import date
     4|from decimal import Decimal
     5|from sqlite3 import Connection
     6|
     7|from jarvis_finance.services.budget_common import row_to_dict
     8|from jarvis_finance.services.budget_transactions import list_budget_transactions
     9|
    10|
    11|def _current_month(conn: Connection) -> str:
    12|    tx = conn.execute("SELECT substr(MAX(transaction_date),1,7) AS month FROM budget_transactions").fetchone()
    13|    if tx and tx["month"]:
    14|        return tx["month"]
    15|    plan = conn.execute("SELECT MIN(plan_month) AS month FROM budget_plan_items WHERE is_active=1").fetchone()
    16|    if plan and plan["month"]:
    17|        return plan["month"]
    18|    return conn.execute("SELECT substr(date('now'),1,7)").fetchone()[0]
    19|
    20|
    21|def _amount(row) -> Decimal:
    22|    return Decimal(str(row["amount_chf"] or row["amount_original"] or "0"))
    23|
    24|
    25|def _monthly_plan_amount(plan) -> Decimal:
    26|    if plan["monthly_amount_chf"]:
    27|        return Decimal(str(plan["monthly_amount_chf"]))
    28|    if plan["annual_amount_chf"]:
    29|        return Decimal(str(plan["annual_amount_chf"])) / Decimal("12")
    30|    return Decimal("0")
    31|
    32|
    33|def _annual_plan_amount(plan) -> Decimal:
    34|    if plan["annual_amount_chf"]:
    35|        return Decimal(str(plan["annual_amount_chf"]))
    36|    if plan["monthly_amount_chf"]:
    37|        return Decimal(str(plan["monthly_amount_chf"])) * Decimal("12")
    38|    return Decimal("0")
    39|
    40|
    41|def _fmt(value: Decimal) -> str:
    42|    return format(value.quantize(Decimal("0.01")), "f")
    43|
    44|
    45|def _category_meta(conn: Connection) -> dict[str, dict]:
    46|    rows = conn.execute("SELECT category_id, parent_category_id, name, category_type, sort_order FROM budget_categories WHERE is_active=1").fetchall()
    47|    return {str(r["category_id"]): row_to_dict(r) for r in rows}
    48|
    49|
    50|def _ancestor_ids(category_id: str | None, meta: dict[str, dict]) -> list[str]:
    51|    if not category_id or category_id not in meta:
    52|        return []
    53|    out: list[str] = []
    54|    parent = meta[category_id].get("parent_category_id")
    55|    seen: set[str] = set()
    56|    while parent and parent in meta and parent not in seen:
    57|        out.append(str(parent))
    58|        seen.add(str(parent))
    59|        parent = meta[str(parent)].get("parent_category_id")
    60|    return out
    61|
    62|
    63|def get_monthly_summary(conn: Connection, month: str | None = None) -> dict:
    64|    selected_month = month or _current_month(conn)
    65|    rows = conn.execute("SELECT * FROM budget_transactions WHERE status='confirmed' AND substr(transaction_date,1,7)=?", (selected_month,)).fetchall()
    66|    income = Decimal("0")
    67|    expense = Decimal("0")
    68|    for row in rows:
    69|        amount = _amount(row)
    70|        if row["transaction_type"] in {"income", "refund"}:
    71|            income += amount
    72|        elif row["transaction_type"] in {"expense", "fee"}:
    73|            expense += amount
    74|    return {"month": selected_month, "income_chf": _fmt(income), "expense_chf": _fmt(expense), "net_cashflow_chf": _fmt(income - expense), "transaction_count": len(rows)}
    75|
    76|
    77|def get_category_summary(conn: Connection, month: str | None = None) -> list[dict]:
    78|    selected_month = month or _current_month(conn)
    79|    rows = conn.execute(
    80|        """
    81|        SELECT COALESCE(c.category_id, 'uncategorized') AS summary_category_id,
    82|               COALESCE(c.name, 'Unkategorisiert') AS summary_category_name,
    83|               t.transaction_type,
    84|               t.amount_chf,
    85|               t.amount_original,
    86|               COUNT(*) AS count
    87|        FROM budget_transactions t
    88|        LEFT JOIN budget_categories c ON c.category_id=t.category_id
    89|        WHERE t.status='confirmed' AND substr(t.transaction_date,1,7)=?
    90|          AND t.transaction_type IN ('income','expense','refund','fee')
    91|        GROUP BY COALESCE(c.category_id, 'uncategorized'), COALESCE(c.name, 'Unkategorisiert'), t.transaction_type, t.amount_chf, t.amount_original
    92|        """,
    93|        (selected_month,),
    94|    ).fetchall()
    95|    totals: dict[str, dict] = {}
    96|    for row in rows:
    97|        item = totals.setdefault(row["summary_category_id"], {"category_id": row["summary_category_id"], "category_name": row["summary_category_name"], "amount_chf": Decimal("0"), "transaction_count": 0})
    98|        item["amount_chf"] += Decimal(str(row["amount_chf"] or row["amount_original"] or "0"))
    99|        item["transaction_count"] += int(row["count"])
   100|    result = list(totals.values())
   101|    result.sort(key=lambda x: x["amount_chf"], reverse=True)
   102|    return [{**r, "amount_chf": _fmt(r["amount_chf"])} for r in result]
   103|
   104|
   105|def _months_denominator(year: str, months_with_bookings: int) -> int:
   106|    today = date.today()
   107|    if str(today.year) == year:
   108|        elapsed = today.month
   109|    else:
   110|        elapsed = 12
   111|    return max(1, min(12, max(months_with_bookings, elapsed)))
   112|
   113|
   114|def _status(category_type: str, budget_year: Decimal, forecast_year: Decimal, actual_year: Decimal, months_with_bookings: int) -> str:
   115|    if budget_year <= 0 and actual_year > 0:
   116|        return "ohne Budget"
   117|    if actual_year == 0 or months_with_bookings == 0 or budget_year <= 0:
   118|        return "Noch keine Daten"
   119|    ratio = forecast_year / budget_year
   120|    if category_type == "income":
   121|        if ratio >= Decimal("1"):
   122|            return "Grün"
   123|        if ratio >= Decimal("0.90"):
   124|            return "Gelb"
   125|        return "Rot"
   126|    if ratio > Decimal("1.15"):
   127|        return "Kritisch"
   128|    if ratio > Decimal("1"):
   129|        return "Rot"
   130|    if ratio >= Decimal("0.90"):
   131|        return "Gelb"
   132|    return "Grün"
   133|
   134|
   135|def get_budget_status_by_category(conn: Connection, *, year: str = "2026") -> list[dict]:
   136|    meta = _category_meta(conn)
   137|    plan_rows = conn.execute(
   138|        """
   139|        SELECT p.*, c.name AS category_name, c.category_type, c.sort_order
   140|        FROM budget_plan_items p
   141|        JOIN budget_categories c ON c.category_id=p.category_id
   142|        WHERE p.is_active=1 AND c.is_active=1 AND substr(p.plan_month,1,4)=?
   143|        ORDER BY c.sort_order, c.name, p.name
   144|        """,
   145|        (year,),
   146|    ).fetchall()
   147|    tx_rows = conn.execute(
   148|        """
   149|        SELECT category_id, transaction_date, COALESCE(amount_chf, amount_original, '0') AS amount
   150|        FROM budget_transactions
   151|        WHERE status='confirmed' AND substr(transaction_date,1,4)=?
   152|          AND transaction_type IN ('income','refund','expense','fee')
   153|        """,
   154|        (year,),
   155|    ).fetchall()
   156|    actual: dict[str, tuple[Decimal, set[str]]] = {}
   157|    monthly_actuals: dict[str, dict[str, Decimal]] = {}
   158|    rollup_child_ids: dict[str, set[str]] = {}
   159|
   160|    def add_actual(category_id: str, amount: Decimal, month_no: str, source_child: str | None = None) -> None:
   161|        total, months = actual.get(category_id, (Decimal("0"), set()))
   162|        months.add(month_no)
   163|        actual[category_id] = (total + amount, months)
   164|        monthly_actuals.setdefault(category_id, {})[month_no] = monthly_actuals.setdefault(category_id, {}).get(month_no, Decimal("0")) + amount
   165|        if source_child:
   166|            rollup_child_ids.setdefault(category_id, set()).add(source_child)
   167|
   168|    grouped: dict[str, dict] = {}
   169|    for row in tx_rows:
   170|        category_id = str(row["category_id"] or "")
   171|        if not category_id or category_id not in meta:
   172|            continue
   173|        amount = Decimal(str(row["amount"] or "0"))
   174|        month_no = str(row["transaction_date"] or "")[5:7]
   175|        add_actual(category_id, amount, month_no)
   176|        cat = meta[category_id]
   177|        grouped.setdefault(category_id, {"category_id": category_id, "category": cat["name"], "category_type": cat["category_type"], "sort_order": cat.get("sort_order") or 999, "budget_month": Decimal("0"), "budget_year": Decimal("0"), "plans": [], "has_plan": False, "is_rollup": False})
   178|        for ancestor_id in _ancestor_ids(category_id, meta):
   179|            ancestor = meta[ancestor_id]
   180|            add_actual(ancestor_id, amount, month_no, category_id)
   181|            grouped.setdefault(ancestor_id, {"category_id": ancestor_id, "category": ancestor["name"], "category_type": ancestor["category_type"], "sort_order": ancestor.get("sort_order") or 999, "budget_month": Decimal("0"), "budget_year": Decimal("0"), "plans": [], "has_plan": False, "is_rollup": True})["is_rollup"] = True
   182|
   183|    for plan in plan_rows:
   184|        category_id = str(plan["category_id"])
   185|        item = grouped.setdefault(category_id, {
   186|            "category_id": category_id,
   187|            "category": plan["category_name"],
   188|            "category_type": plan["category_type"],
   189|            "sort_order": plan["sort_order"] or 999,
   190|            "budget_month": Decimal("0"),
   191|            "budget_year": Decimal("0"),
   192|            "plans": [],
   193|            "has_plan": False,
   194|            "is_rollup": bool(rollup_child_ids.get(category_id)),
   195|        })
   196|        bm = _monthly_plan_amount(plan)
   197|        by = _annual_plan_amount(plan)
   198|        item["budget_month"] += bm
   199|        item["budget_year"] += by
   200|        item["plans"].append(plan["name"])
   201|        item["has_plan"] = True
   202|    results: list[dict] = []
   203|    for category_id, item in grouped.items():
   204|        actual_year, month_set = actual.get(category_id, (Decimal("0"), set()))
   205|        months = len(month_set)
   206|        if actual_year == 0 and not item["has_plan"]:
   207|            continue
   208|        denom = _months_denominator(year, months)
   209|        actual_avg = actual_year / Decimal(denom)
   210|        forecast_year = actual_avg * Decimal("12") if actual_year else Decimal("0")
   211|        forecast_month = forecast_year / Decimal("12") if forecast_year else Decimal("0")
   212|        deviation = forecast_year - item["budget_year"]
   213|        status = _status(item["category_type"], item["budget_year"], forecast_year, actual_year, months)
   214|        results.append({
   215|            "category_id": category_id,
   216|            "category": item["category"],
   217|            "category_type": item["category_type"],
   218|            "budget_month": _fmt(item["budget_month"]) if item["has_plan"] else None,
   219|            "budget_year": _fmt(item["budget_year"]) if item["has_plan"] else None,
   220|            "has_budget": bool(item["has_plan"]),
   221|            "actual_year_to_date": _fmt(actual_year),
   222|            "monthly_actuals": {f"{m:02d}": _fmt(monthly_actuals.get(category_id, {}).get(f"{m:02d}", Decimal("0"))) for m in range(1, 13)},
   223|            "actual_avg_month": _fmt(actual_avg),
   224|            "forecast_year": _fmt(forecast_year),
   225|            "forecast_avg_month": _fmt(forecast_month),
   226|            "deviation_year": _fmt(deviation),
   227|            "deviation_month": _fmt(deviation / Decimal("12")),
   228|            "status": status,
   229|            "status_hint": "Ausgaben vorhanden, aber kein Budgetplan" if status == "ohne Budget" and item["category_type"] != "income" else ("Einnahmen vorhanden, aber kein Budgetplan" if status == "ohne Budget" else ""),
   230|            "months_with_bookings": months,
   231|            "plan_names": item["plans"],
   232|            "is_rollup": bool(item.get("is_rollup")),
   233|            "rollup_child_count": len(rollup_child_ids.get(category_id, set())),
   234|        })
   235|    results.sort(key=lambda r: (0 if r["category_type"] == "income" else 1, meta.get(r["category_id"], {}).get("sort_order") or 999, r["category"]))
   236|    return results
   237|
   238|
   239|def get_budget_charts_2026(conn: Connection, *, year: str = "2026") -> dict:
   240|    cash_rows = conn.execute(
   241|        """
   242|        SELECT substr(transaction_date,1,7) AS month,
   243|               SUM(CASE WHEN transaction_type IN ('income','refund') THEN CAST(COALESCE(amount_chf, amount_original, '0') AS REAL) ELSE 0 END) AS income,
   244|               SUM(CASE WHEN transaction_type IN ('expense','fee') THEN CAST(COALESCE(amount_chf, amount_original, '0') AS REAL) ELSE 0 END) AS expense
   245|        FROM budget_transactions
   246|        WHERE status='confirmed' AND substr(transaction_date,1,4)=?
   247|        GROUP BY substr(transaction_date,1,7)
   248|        ORDER BY month
   249|        """,
   250|        (year,),
   251|    ).fetchall()
   252|    cashflow = []
   253|    for r in cash_rows:
   254|        income = Decimal(str(r["income"] or 0)); expense = Decimal(str(r["expense"] or 0))
   255|        cashflow.append({"year": year, "month": r["month"], "income_chf": _fmt(income), "expense_chf": _fmt(expense), "net_cashflow_chf": _fmt(income - expense)})
   256|    status_rows = get_budget_status_by_category(conn, year=year)
   257|    budget_vs_actual = [
   258|        {"year": year, "category_id": r["category_id"], "category": r["category"], "category_type": r["category_type"], "budget_year": r["budget_year"], "actual_year_to_date": r["actual_year_to_date"], "forecast_year": r["forecast_year"], "status": r["status"]}
   259|        for r in status_rows
   260|    ]
   261|

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


     1|from __future__ import annotations
     2|
     3|import csv
     4|import hashlib
     5|import json
     6|import re
     7|from collections import defaultdict
     8|from dataclasses import dataclass
     9|from datetime import datetime, timedelta
    10|from decimal import Decimal, InvalidOperation
    11|from sqlite3 import Connection
    12|from typing import Any
    13|
    14|from fastapi import HTTPException
    15|
    16|from jarvis_finance.audit.log import record_audit_event
    17|from jarvis_finance.services.budget_common import new_id, now, row_to_dict
    18|from jarvis_finance.services.budget_transactions import confirm_budget_transaction
    19|
    20|SUBSCRIPTION_PATTERNS = ["APPLE", "APPLE.COM", "GOOGLE", "AMAZON PRIME", "NETFLIX", "DISNEY", "DISNEYPLUS"]
    21|MIGROS_PATTERNS = ["MIGROS", "MIGROL", "CUMULUS", "MMM "]
    22|GALAXUS_PATTERNS = ["GALAXUS"]
    23|INCOME_PATTERNS = ["LOHN", "SALARY", "ARBEITGEBER", "PAYROLL", "GEHALT"]
    24|TRANSFER_PATTERNS = ["RAIFFEISEN", "AKB", "MARCEL", "MELANIE", "ÜBERTRAG", "UEBERTRAG", "TRANSFER", "EIGENES KONTO", "GUTSCHRIFT VON"]
    25|
    26|
    27|def _decimal_text(value: object) -> str | None:
    28|    if value in (None, ""):
    29|        return None
    30|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-")
    31|    text = text.replace("+", "")
    32|    if text.count(",") == 1 and text.count(".") == 0:
    33|        text = text.replace(",", ".")
    34|    text = text.replace(" ", "")
    35|    try:
    36|        return format(abs(Decimal(text)), "f")
    37|    except (InvalidOperation, ValueError):
    38|        return None
    39|
    40|
    41|def _signed_decimal(value: object) -> Decimal | None:
    42|    if value in (None, ""):
    43|        return None
    44|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-").replace("+", "")
    45|    if text.count(",") == 1 and text.count(".") == 0:
    46|        text = text.replace(",", ".")
    47|    text = text.replace(" ", "")
    48|    try:
    49|        return Decimal(text)
    50|    except (InvalidOperation, ValueError):
    51|        return None
    52|
    53|
    54|def _normalise(value: object) -> str:
    55|    return str(value or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("\ufeff", "")
    56|
    57|
    58|def _upper_ascii(value: object) -> str:
    59|    return _normalise(value).upper()
    60|
    61|
    62|def _date_text(value: object) -> str | None:
    63|    text = str(value or "").strip()
    64|    if not text:
    65|        return None
    66|    if re.match(r"^\d{4}-\d{2}-\d{2}", text):
    67|        return text[:10]
    68|    m = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})", text)
    69|    if m:
    70|        d, mo, y = m.groups()
    71|        return f"{y}-{int(mo):02d}-{int(d):02d}"
    72|    return text[:10] if len(text) >= 10 else None
    73|
    74|
    75|def _category_name(conn: Connection, category_id: str | None) -> str | None:
    76|    if not category_id:
    77|        return None
    78|    row = conn.execute("SELECT name FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
    79|    return str(row["name"]) if row else None
    80|
    81|
    82|def _category_by_name(conn: Connection, needles: list[str]) -> tuple[str | None, str | None]:
    83|    cats = conn.execute("SELECT category_id, name FROM budget_categories WHERE is_active=1").fetchall()
    84|    for needle in needles:
    85|        n = _normalise(needle)
    86|        for cat in cats:
    87|            if n and (n in _normalise(cat["name"]) or _normalise(cat["name"]) in n):
    88|                return str(cat["category_id"]), str(cat["name"])
    89|    return None, None
    90|
    91|
    92|def suggest_category(conn: Connection, description: str, source_label: str = "") -> tuple[str | None, str | None, str, bool]:
    93|    text = _normalise(f"{source_label} {description}")
    94|    if "migros" in text or "cumulus" in text:
    95|        cid, cname = _category_by_name(conn, ["Essen & Haushalt", "Grosseinkauf", "Essen", "Haushalt", "Leben"])
    96|        return cid, cname or "Essen & Haushalt / Grosseinkauf", "0.80", cid is None
    97|    keyword_map = [
    98|        (["krankenkasse", "versicherung", "axa", "zurich", "css", "helsana"], ["Versicherungen", "Krankenkasse"]),
    99|        (["coop", "denner", "aldi", "lidl"], ["Essen", "Haushalt", "Leben"]),
   100|        (["sbb", "zug", "bahn", "parking", "tankstelle", "mazda"], ["Mobilität", "Auto"]),
   101|        (["lohn", "salary", "einkommen", "gutschrift"], ["Einnahmen", "Lohn"]),
   102|    ]
   103|    for words, cats in keyword_map:
   104|        if any(w in text for w in words):
   105|            cid, cname = _category_by_name(conn, cats)
   106|            return cid, cname or cats[0], "0.65", cid is None
   107|    return None, None, "0.20", True
   108|
   109|
   110|def _duplicate_of(conn: Connection, tx_date: str | None, amount: str | None, description: str) -> str | None:
   111|    if not tx_date or not amount:
   112|        return None
   113|    row = conn.execute(
   114|        """
   115|        SELECT budget_transaction_id FROM budget_transactions
   116|        WHERE transaction_date=? AND status IN ('confirmed','reversed')
   117|          AND COALESCE(amount_chf, amount_original)=?
   118|          AND lower(description)=lower(?)
   119|        ORDER BY created_at LIMIT 1
   120|        """,
   121|        (tx_date, amount, description),
   122|    ).fetchone()
   123|    return str(row["budget_transaction_id"]) if row else None
   124|
   125|
   126|def _candidate_key(source_label: str, row_ref: str, tx_date: str | None, amount: str | None, description: str) -> str:
   127|    raw = "|".join([source_label, row_ref, tx_date or "", amount or "", description])
   128|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
   129|
   130|
   131|def _fingerprint(parts: list[object]) -> str:
   132|    raw = "|".join(str(p or "").strip() for p in parts)
   133|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
   134|
   135|
   136|def _insert_candidate(conn: Connection, row: dict[str, Any]) -> bool:
   137|    existing = conn.execute(
   138|        """
   139|        SELECT 1 FROM budget_transaction_candidates
   140|        WHERE source_file_label=? AND source_row_or_range=? AND transaction_date IS ?
   141|          AND description=? AND COALESCE(amount_original,'')=COALESCE(?, '')
   142|        """,
   143|        (row["source_file_label"], row.get("source_row_or_range"), row.get("transaction_date"), row["description"], row.get("amount_original")),
   144|    ).fetchone()
   145|    if existing:
   146|        return False
   147|    conn.execute(
   148|        """
   149|        INSERT INTO budget_transaction_candidates(
   150|          transaction_candidate_id, source_file_label, source_row_or_range, source_type,
   151|          transaction_date, description, merchant, amount_original, currency_original,
   152|          proposed_category_id, proposed_category_name, duplicate_of_transaction_id,
   153|          confidence, requires_review, status, notes, created_at, updated_at,
   154|          classification, review_reason, rule_id, rule_name, source_priority, covered_by_source,
   155|          receipt_key, linked_candidate_id, account_source, raw_fingerprint
   156|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   157|        """,
   158|        (
   159|            row["transaction_candidate_id"], row["source_file_label"], row.get("source_row_or_range"), row.get("source_type") or "csv_seed",
   160|            row.get("transaction_date"), row["description"], row.get("merchant"), row.get("amount_original"), row.get("currency_original") or "CHF",
   161|            row.get("proposed_category_id"), row.get("proposed_category_name"), row.get("duplicate_of_transaction_id"),
   162|            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"],
   163|            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"),
   164|            row.get("receipt_key"), row.get("linked_candidate_id"), row.get("account_source"), row.get("raw_fingerprint"),
   165|        ),
   166|    )
   167|    return True
   168|
   169|
   170|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]:
   171|    created = duplicates = review = suggested = ignored = confirmed = 0
   172|    for idx, raw in enumerate(rows, start=1):
   173|        if isinstance(raw, dict):
   174|            low = {_normalise(k): v for k, v in raw.items()}
   175|            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()
   176|            merchant = str(low.get("merchant") or low.get("merchantname") or low.get("filiale") or low.get("haendler") or "").strip() or None
   177|            tx_date = _date_text(low.get("datum") or low.get("date") or low.get("buchungsdatum") or low.get("valutadate"))
   178|            amount = _decimal_text(low.get("betrag") or low.get("amount") or low.get("umsatz") or low.get("wert") or low.get("originalamount"))
   179|        else:
   180|            cells = [str(c).strip() for c in raw]
   181|            description = next((c for c in cells if c and _decimal_text(c) is None and not c[:4].isdigit()), "")
   182|            merchant = None
   183|            tx_date = next((_date_text(c) for c in cells if _date_text(c)), None)
   184|            amount = next((_decimal_text(c) for c in cells if _decimal_text(c) is not None), None)
   185|        if not description or not amount:
   186|            ignored += 1
   187|            continue
   188|        cid, cname, conf, needs_review = suggest_category(conn, description, source_file_label)
   189|        dup = _duplicate_of(conn, tx_date, amount, description)
   190|        status = "duplicate" if dup else ("needs_review" if needs_review else "pending")
   191|        if dup:
   192|            duplicates += 1
   193|        if cid:
   194|            suggested += 1
   195|        if needs_review:
   196|            review += 1
   197|        ts = now()
   198|        row_ref = f"R{idx}"
   199|        inserted = _insert_candidate(conn, {
   200|            "transaction_candidate_id": "btxcand_" + _candidate_key(source_file_label, row_ref, tx_date, amount, description),
   201|            "source_file_label": source_file_label,
   202|            "source_row_or_range": row_ref,
   203|            "source_type": source_type,
   204|            "transaction_date": tx_date,
   205|            "description": description[:300],
   206|            "merchant": merchant,
   207|            "amount_original": amount,
   208|            "currency_original": "CHF",
   209|            "proposed_category_id": cid,
   210|            "proposed_category_name": cname,
   211|            "duplicate_of_transaction_id": dup,
   212|            "confidence": conf,
   213|            "requires_review": needs_review or bool(dup),
   214|            "status": status,
   215|            "notes": "mvp_light_category_suggestion",
   216|            "classification": "legacy_csv_seed",
   217|            "review_reason": "legacy_seed_review" if needs_review else "legacy_seed_suggested",
   218|            "raw_fingerprint": _fingerprint([source_file_label, row_ref, tx_date, amount, description]),
   219|            "created_at": ts,
   220|            "updated_at": ts,
   221|        })
   222|        if inserted:
   223|            created += 1
   224|    conn.commit()
   225|    return {"candidate_count": created, "suggested_count": suggested, "review_required_count": review, "possible_duplicate_count": duplicates, "ignored_count": ignored, "confirmed_count": confirmed}
   226|
   227|
   228|def seed_transaction_candidates_from_csv_text(conn: Connection, text: str, *, source_file_label: str, source_type: str = "csv_seed") -> dict[str, int]:
   229|    sample = text[:4096]
   230|    dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") if sample.strip() else csv.excel
   231|    reader = csv.DictReader(text.splitlines(), dialect=dialect)
   232|    if reader.fieldnames:
   233|        return seed_transaction_candidates_from_rows(conn, list(reader), source_file_label=source_file_label, source_type=source_type)
   234|    return seed_transaction_candidates_from_rows(conn, list(csv.reader(text.splitlines(), dialect=dialect)), source_file_label=source_file_label, source_type=source_type)
   235|
   236|
   237|def seed_migros_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, auto_category_id: str | None = None, threshold_chf: str = "50.00") -> dict[str, int]:
   238|    threshold = Decimal(threshold_chf)
   239|    auto_category_name = _category_name(conn, auto_category_id)
   240|    grouped: dict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
   241|    ignored = 0
   242|    for idx, raw in enumerate(rows, start=1):
   243|        low = {_normalise(k): v for k, v in raw.items()}
   244|        tx_date = _date_text(low.get("datum") or low.get("date"))
   245|        time = str(low.get("zeit") or low.get("time") or "").strip()
   246|        store = str(low.get("filiale") or low.get("store") or "Migros").strip()
   247|        register = str(low.get("kasse") or low.get("kassennummer") or low.get("register") or "").strip()
   248|        txn = str(low.get("transaktion") or low.get("transaktionsnummer") or low.get("bon") or low.get("bon-id") or "").strip()
   249|        amount = _decimal_text(low.get("umsatz") or low.get("betrag") or low.get("amount"))
   250|        if not tx_date or not amount:
   251|            ignored += 1
   252|            continue
   253|        key = _fingerprint([tx_date, time, store, register, txn])
   254|        grouped[key].append((idx, raw))
   255|    created = auto = review = line_count = 0
   256|    for key, items in grouped.items():
   257|        first_low = {_normalise(k): v for k, v in items[0][1].items()}
   258|        tx_date = _date_text(first_low.get("datum") or first_low.get("date"))
   259|        time = str(first_low.get("zeit") or first_low.get("time") or "").strip()
   260|        store = str(first_low.get("filiale") or first_low.get("store") or "Migros").strip() or "Migros"
   261|

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


1|<template>
     2|

     3|
     4|
Budget Modul Phase 1.4

     5|
Budgetstatus nach Kategorie

     6|
Plan, Ist, Prognose und Ampel je JARVIS-Kategorie. Zeile anklicken öffnet Buchungsdetails.

     7|
     8|
     9|

    10|
{{ error }}

    11|      <select v-model="activeYear" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" @change="refresh"><option value="2026">Budgetjahr 2026</option><option value="2025">Referenzjahr 2025</option></select>
    12|

    13|
{{ message }}

    14|
    15|

    16|

    17|
{{ card.label }}

    18|
{{ card.value }}

    19|
{{ card.hint }}

    20|

    21|

    22|
    23|

    24|

    25|

{{ row.category }}

{{ row.status_hint || row.category_type }}

{{ row.status }}

    26|

    27|

Budget Monat

{{ formatMaybeMoney(row.budget_month) }}

    28|

Ist Monat

{{ formatMoney(row.monthly_actuals?.[monthKeys[months[0]]] || '0') }}

    29|

Prognose

{{ formatMoney(row.forecast_year) }}

    30|

Abweichung

{{ formatMoney(row.deviation_year) }}

    31|

    32|

    33|

    34|
    35|

    36|

    37|

    38|
Budgetstatus nach Kategorie

    39|
Monats- und Jahresbudget stehen in einer Zeile. Keine Scheingenauigkeit: ohne Ist-Daten bleibt der Status leer/neutral.

    40|

    41|        {{ overview?.current_month || '—' }}
    42|

    43|

    44|

    45|

    46|

    47|              Kategorie
    48|              Budget Monat
    49|              Budget Jahr
    50|              Ist Jahr bisher
    51|              {{ m }}
    52|              Ist Ø Monat
    53|              Prognose Jahr
    54|              Prognose Ø Monat
    55|              Abweichung
    56|              Status
    57|

    58|

    59|

    60|

    61|              {{ row.category }}{{ row.category_type }}
    62|              {{ formatMaybeMoney(row.budget_month) }}
    63|              {{ formatMaybeMoney(row.budget_year) }}
    64|              {{ formatMoney(row.actual_year_to_date) }}
    65|              {{ formatMoney(row.monthly_actuals?.[monthKeys[m]] || '0') }}
    66|              {{ formatMoney(row.actual_avg_month) }}
    67|              {{ formatMoney(row.forecast_year) }}
    68|              {{ formatMoney(row.forecast_avg_month) }}
    69|              {{ formatMoney(row.deviation_year) }}
    70|              {{ row.status }}
    71|

    72|
Noch keine bestätigten Budgetpläne.

    73|

    74|

    75|

    76|

    77|
    78|

    79|

    80|
Review-Hinweise

    81|

    82|

Offene Kategorien ohne Ist-Werte

{{ overview?.open_categories_without_actuals ?? 0 }}

    83|

Seed Review offen

{{ overview?.pending_seed_candidate_count ?? 0 }}

    84|

Import Review offen

{{ overview?.pending_transaction_candidate_count ?? 0 }}

    85|

    86|

    87|

    88|
Letzte Transaktionen

    89|

    90|
{{ tx.transaction_date }} · {{ tx.description }}{{ tx.currency_original }} {{ tx.amount_original }}

    91|
Noch keine Buchungen.

    92|

    93|

    94|

    95|
    96|

    97|

    98|
Einnahmen vs Ausgaben pro Monat

    99|
Noch keine bestätigten 2026-Budgetbuchungen.

   100|

{{ row.month }} · Einnahmen {{ formatMoney(row.income_chf) }} · Ausgaben {{ formatMoney(row.expense_chf) }} · Netto {{ formatMoney(row.net_cashflow_chf) }}

   101|

   102|

   103|
Budget vs Ist pro Kategorie

   104|

{{ row.category }} · Budget {{ formatMaybeMoney(row.budget_year) }} · Ist {{ formatMoney(row.actual_year_to_date) }}

   105|

   106|

   107|
Prognose/Ampel

   108|
Keine prognostizierten Überschreitungen.

   109|
{{ row.category }} · {{ row.status }} · Prognose {{ formatMoney(row.forecast_year) }}

   110|

   111|

   112|
   113|
   114|

   115|

   116|
Kategorie-Details

   117|
{{ selected.category }}

   118|
Buchungen filtern und Kategorie/Tags/Beschreibung korrigieren. Betragskorrekturen nur via Adjustment/Reversal.

   119|

   120|        <button class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-200" @click="selected = null">Schließen</button>
   121|

   122|

   123|        <input v-model="filters.month" placeholder="Monat YYYY-MM" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" />
   124|        <input v-model="filters.account_id" placeholder="Konto-ID" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" />
   125|        <input v-model="filters.merchant" placeholder="Händler" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" />
   126|        <input v-model="filters.tag" placeholder="Tag" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" />
   127|        <select v-model="filters.status" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"><option value="">Status</option><option>confirmed</option><option>reversed</option></select>
   128|

   129|      <button class="mt-3 rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950" @click="loadTransactions">Filtern</button>
   130|

   131|

   132|
{{ tx.transaction_date }} · {{ tx.description }}{{ tx.currency_original }} {{ tx.amount_original }}

   133|

   134|            <select v-model="edits[tx.budget_transaction_id].category_id" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white">
   135|              <option value="">Unkategorisiert</option>
   136|              <option v-for="cat in flatCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option>
   137|            </select>
   138|            <input v-model="edits[tx.budget_transaction_id].description" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
   139|            <input v-model="edits[tx.budget_transaction_id].tag_names" placeholder="Tags, kommagetrennt" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
   140|

   141|          <button class="mt-3 rounded-lg border border-emerald-500/60 px-3 py-2 text-xs font-semibold text-emerald-200" @click="saveTransaction(tx.budget_transaction_id)">Änderung mit Audit speichern</button>
   142|

   143|
Keine Buchungen für diese Filter.

   144|

   145|
   146|

   147|</template>
   148|
   208|


Explanation:
**Continuing budget imports**

I need to continue working on budget imports, specifically around Migros, and I should confirm the details or any updates. It seems like there’s some important information I need to verify to make everything accurate. I just want to ensure that I’m handling the budget effectively and this confirmation is really crucial to keep things on track. Let's make sure I touch base with the relevant details.