diff --git a/Plan.md b/Plan.md index 20175c4..d079a82 100644 --- a/Plan.md +++ b/Plan.md @@ -34,7 +34,7 @@ **Interfaces:** - Produces: `app.config.get_settings() -> Settings` mit Feldern `database_url: str`, `api_key: str`, `gui_user: str`, `gui_password_hash: str`, `session_secret: str`, `inbox_dir: Path`, `uploads_dir: Path`, `warn_threshold: Decimal`, `horizon_days: int` (Default 548 = 18 Monate). -- [ ] **Step 1: Dateien anlegen** +- [x] **Step 1: Dateien anlegen** `finance/requirements.txt`: ``` @@ -122,7 +122,7 @@ def test_defaults(): assert s.gui_user == "admin" ``` -- [ ] **Step 2: venv anlegen, Dependencies installieren, Test ausführen** +- [x] **Step 2: venv anlegen, Dependencies installieren, Test ausführen** ```bash cd /home/wlfb/bin/finance @@ -131,7 +131,7 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt ``` Erwartet: `1 passed`. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash cd /home/wlfb/bin && git add finance && git commit -m "feat: Projekt-Geruest finance/ mit Config und Test-Setup" @@ -150,7 +150,7 @@ cd /home/wlfb/bin && git add finance && git commit -m "feat: Projekt-Geruest fin - Produces: ORM-Klassen `Account, Category, CategoryRule, Statement, Transaction, RecurringItem, PlannedItem, Loan, Scenario, ScenarioLoan, ScenarioModifier, ProjectionPoint, ProjectionResult` (Import aus `app.models.tables`), `app.db.get_session()` (FastAPI-Dependency), `app.db.Base`. - Statement.status ∈ {"draft", "confirmed", "error"}; Transaction.status ∈ {"draft", "confirmed"}; RecurringItem.rhythm ∈ {"monthly", "quarterly", "yearly"}; Loan.repayment_type ∈ {"annuity", "bullet"}; ScenarioModifier.target_type ∈ {"category", "recurring"}, kind ∈ {"percent", "absolute", "remove"}. -- [ ] **Step 1: Failing Test schreiben** +- [x] **Step 1: Failing Test schreiben** `finance/tests/conftest.py`: ```python @@ -201,9 +201,9 @@ def test_account_transaction_roundtrip(db): assert db.query(Transaction).one().amount == Decimal("-50.00") ``` -- [ ] **Step 2: Test ausführen — erwartet FAIL** (`ModuleNotFoundError: app.db`) +- [x] **Step 2: Test ausführen — erwartet FAIL** (`ModuleNotFoundError: app.db`) -- [ ] **Step 3: Implementieren** +- [x] **Step 3: Implementieren** `finance/app/db.py`: ```python @@ -378,9 +378,9 @@ class ProjectionResult(Base): from app.models import tables # noqa: F401 ``` -- [ ] **Step 4: Test ausführen — erwartet PASS** (`.venv/bin/python -m pytest -q`) +- [x] **Step 4: Test ausführen — erwartet PASS** (`.venv/bin/python -m pytest -q`) -- [ ] **Step 5: Alembic initialisieren** +- [x] **Step 5: Alembic initialisieren** ```bash cd /home/wlfb/bin/finance @@ -403,7 +403,7 @@ rm mig_test.sqlite ``` Erwartet: Migration in `alembic/versions/` erzeugt, `upgrade head` läuft fehlerfrei. -- [ ] **Step 6: Commit** — `git add finance && git commit -m "feat: Datenmodell (SQLAlchemy) und Alembic-Migration"` +- [x] **Step 6: Commit** — `git add finance && git commit -m "feat: Datenmodell (SQLAlchemy) und Alembic-Migration"` --- @@ -417,7 +417,7 @@ Erwartet: Migration in `alembic/versions/` erzeugt, `upgrade head` läuft fehler **Interfaces:** - Produces: `add_months(d: date, months: int) -> date`; `annuity_payment(principal: Decimal, annual_rate_pct: Decimal, term_months: int) -> Decimal`; `loan_schedule(principal, annual_rate_pct, term_months, payout_date, repayment_type) -> list[Installment]` mit `Installment(due: date, payment: Decimal, interest: Decimal, principal: Decimal, remaining: Decimal)`; `payment` ist negativ (Ausgabe). -- [ ] **Step 1: Failing Tests schreiben** +- [x] **Step 1: Failing Tests schreiben** `finance/tests/test_loans.py`: ```python @@ -452,9 +452,9 @@ def test_bullet_loan(): assert plan[-1].remaining == Decimal("0.00") ``` -- [ ] **Step 2: Ausführen — erwartet FAIL** (Modul fehlt) +- [x] **Step 2: Ausführen — erwartet FAIL** (Modul fehlt) -- [ ] **Step 3: Implementieren** +- [x] **Step 3: Implementieren** `finance/app/engine/loans.py`: ```python @@ -516,9 +516,9 @@ def loan_schedule(principal: Decimal, annual_rate_pct: Decimal, term_months: int return plan ``` -- [ ] **Step 4: Ausführen — erwartet PASS** +- [x] **Step 4: Ausführen — erwartet PASS** -- [ ] **Step 5: Commit** — `feat: Kredit-Mathematik (Annuitaet, endfaellig)` +- [x] **Step 5: Commit** — `feat: Kredit-Mathematik (Annuitaet, endfaellig)` ### Task 4: Wiederholungs-Termine (Recurrence) @@ -528,7 +528,7 @@ def loan_schedule(principal: Decimal, annual_rate_pct: Decimal, term_months: int **Interfaces:** - Produces: `occurrences(rhythm: str, due_day: int, window_start: date, window_end: date, item_start: date | None = None, item_end: date | None = None) -> list[date]`. -- [ ] **Step 1: Failing Tests** +- [x] **Step 1: Failing Tests** `finance/tests/test_recurrence.py`: ```python @@ -553,9 +553,9 @@ def test_yearly(): assert got == [date(2026, 1, 1), date(2027, 1, 1), date(2028, 1, 1)] ``` -- [ ] **Step 2: Ausführen — FAIL** +- [x] **Step 2: Ausführen — FAIL** -- [ ] **Step 3: Implementieren** +- [x] **Step 3: Implementieren** `finance/app/engine/recurrence.py`: ```python @@ -586,7 +586,7 @@ def occurrences(rhythm: str, due_day: int, window_start: date, window_end: date, return out ``` -- [ ] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Recurrence-Terminberechnung` +- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Recurrence-Terminberechnung` ### Task 5: Projektion, Modifikatoren, Szenario-Zahlungsstrom @@ -599,7 +599,7 @@ def occurrences(rhythm: str, due_day: int, window_start: date, window_end: date, - `scenario.PlainRecurring(name, amount, rhythm, due_day, start_date, end_date, category_id, id)` / `PlainPlanned(name, amount, due, category_id)` / `PlainModifier(target_type, target_id, kind, value)` (Dataclasses). - `scenario.build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned], loan_schedules: list[list[Installment]], loan_payouts: list[tuple[date, Decimal]], modifiers: list[PlainModifier], window_start: date, window_end: date) -> list[tuple[date, Decimal]]`. -- [ ] **Step 1: Failing Tests** +- [x] **Step 1: Failing Tests** `finance/tests/test_projection.py`: ```python @@ -666,9 +666,9 @@ def test_remove_modifier_and_planned_and_loan(): assert all(a != Decimal("-50") for _, a in flows) # Abo entfernt ``` -- [ ] **Step 2: Ausführen — FAIL** +- [x] **Step 2: Ausführen — FAIL** -- [ ] **Step 3: Implementieren** +- [x] **Step 3: Implementieren** `finance/app/engine/projection.py`: ```python @@ -799,7 +799,7 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned] return sorted(flows) ``` -- [ ] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Szenario-Engine (Projektion, Modifikatoren, Zahlungsstrom)` +- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Szenario-Engine (Projektion, Modifikatoren, Zahlungsstrom)` --- @@ -816,7 +816,7 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned] - `detect.detect_bank(text: str) -> str | None` mit Rückgabe `"vr" | "hvb" | "dkb" | None`. - `validate.balance_difference(p: ParsedStatement) -> Decimal` (0 = plausibel); `validate.dedup_hash(account_id: int, booking_date: date, amount: Decimal, purpose: str) -> str`. -- [ ] **Step 1: Failing Tests** +- [x] **Step 1: Failing Tests** `finance/tests/test_parser_base.py`: ```python @@ -870,9 +870,9 @@ def test_dedup_hash_normalizes_whitespace(): assert a == b and len(a) == 64 ``` -- [ ] **Step 2: Ausführen — FAIL** +- [x] **Step 2: Ausführen — FAIL** -- [ ] **Step 3: Implementieren** +- [x] **Step 3: Implementieren** `finance/app/parsers/base.py`: ```python @@ -959,7 +959,7 @@ def dedup_hash(account_id: int, booking_date: date, amount: Decimal, purpose: st return hashlib.sha256(raw.encode()).hexdigest() ``` -- [ ] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Parser-Basis, Bank-Erkennung, Saldo-/Duplikat-Pruefung` +- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Parser-Basis, Bank-Erkennung, Saldo-/Duplikat-Pruefung` ### Task 7: Bank-Parser VR, HVB, DKB (fixture-getrieben) @@ -971,7 +971,7 @@ def dedup_hash(account_id: int, booking_date: date, amount: Decimal, purpose: st **Wichtig:** Die echten PDF-Layouts liegen erst mit den Fixtures vor (Nutzer legt pro Bank mindestens ein echtes PDF unter `finance/tests/fixtures/` ab: `vr_beispiel.pdf`, `hvb_beispiel.pdf`, `dkb_beispiel.pdf` — gitignored). Die Fixture-Tests skippen, solange die Datei fehlt. Die Regex-Konstanten am Dateianfang jedes Parsers sind der einzige anzupassende Teil; Arbeitsschleife pro Bank: Test laufen lassen → bei Fehlschlag mit dem Debug-Skript den Text ansehen → Regex anpassen → wiederholen, bis die Saldo-Prüfung aufgeht. -- [ ] **Step 1: Fixture-README und Tests schreiben** +- [x] **Step 1: Fixture-README und Tests schreiben** `finance/tests/fixtures/README.md`: ```markdown @@ -1014,7 +1014,7 @@ def test_parse_fixture(filename, bank): assert balance_difference(stmt) == Decimal("0") ``` -- [ ] **Step 2: Parser-Gerüst implementieren (gemeinsames Muster)** +- [x] **Step 2: Parser-Gerüst implementieren (gemeinsames Muster)** `finance/app/parsers/vr.py` (HVB/DKB analog — gleiche Struktur, eigene Konstanten; Dateien `hvb.py`/`dkb.py` identisch aufbauen, nur `BANK = "hvb"` bzw. `"dkb"`): ```python @@ -1115,7 +1115,7 @@ def parse_pdf(path: Path) -> ParsedStatement: return PARSERS[bank](path) ``` -- [ ] **Step 3: Debug-Hilfsskript anlegen** — `finance/scripts/dump_pdf_text.py`: +- [x] **Step 3: Debug-Hilfsskript anlegen** — `finance/scripts/dump_pdf_text.py`: ```python """PDF-Text anzeigen, um Parser-Regexe abzugleichen: python scripts/dump_pdf_text.py """ import sys @@ -1128,9 +1128,9 @@ with pdfplumber.open(sys.argv[1]) as pdf: print(page.extract_text() or "(kein Text)") ``` -- [ ] **Step 4: Fixture-Abgleich je Bank** — Für jede vorhandene Fixture: `.venv/bin/python -m pytest tests/test_bank_parsers.py -v` ausführen; bei FAIL mit `dump_pdf_text.py` den echten Text ansehen und **nur die RE_\*-Konstanten** (und falls nötig die Salden-Begriffe, z. B. „Kontostand am…") des jeweiligen Parsers anpassen, bis der Test inkl. `balance_difference == 0` grün ist. Fehlt eine Fixture noch, bleibt der Test geskippt — dann diesen Zustand committen und den Abgleich nachholen, sobald die Datei da ist. +- [x] **Step 4: Fixture-Abgleich je Bank** — Für jede vorhandene Fixture: `.venv/bin/python -m pytest tests/test_bank_parsers.py -v` ausführen; bei FAIL mit `dump_pdf_text.py` den echten Text ansehen und **nur die RE_\*-Konstanten** (und falls nötig die Salden-Begriffe, z. B. „Kontostand am…") des jeweiligen Parsers anpassen, bis der Test inkl. `balance_difference == 0` grün ist. Fehlt eine Fixture noch, bleibt der Test geskippt — dann diesen Zustand committen und den Abgleich nachholen, sobald die Datei da ist. -- [ ] **Step 5: Alle Tests ausführen — PASS/SKIP** · **Step 6: Commit** — `feat: PDF-Parser VR/HVB/DKB mit Registry und Fixture-Tests` +- [x] **Step 5: Alle Tests ausführen — PASS/SKIP** · **Step 6: Commit** — `feat: PDF-Parser VR/HVB/DKB mit Registry und Fixture-Tests` --- @@ -1145,7 +1145,7 @@ with pdfplumber.open(sys.argv[1]) as pdf: - Produces: `app.main.app` (FastAPI); `auth.hash_password(pw: str) -> str` (Format `salt$hex`, PBKDF2-SHA256); `auth.verify_password(pw: str, stored: str) -> bool`; Dependency `auth.require_auth` (akzeptiert `Authorization: Bearer ` **oder** gültiges Session-Cookie `fb_session`, sonst 401); Routen `POST /login` (Form: `username`, `password`; setzt Cookie, Redirect `/`), `GET /login` (Formular), `POST /logout`. - Consumes: `get_settings`, `get_session`. -- [ ] **Step 1: conftest erweitern + Failing Tests** +- [x] **Step 1: conftest erweitern + Failing Tests** In `finance/tests/conftest.py` ergänzen: ```python @@ -1188,9 +1188,9 @@ def test_login_flow(client): assert client.get("/api/accounts").status_code == 200 # Cookie reicht ``` -- [ ] **Step 2: Ausführen — FAIL** +- [x] **Step 2: Ausführen — FAIL** -- [ ] **Step 3: Implementieren** +- [x] **Step 3: Implementieren** `finance/app/auth.py`: ```python @@ -1294,7 +1294,7 @@ def _placeholder_accounts(): # wird in Task 9 durch Router ersetzt return [] ``` -- [ ] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: FastAPI-App mit API-Key- und Session-Auth` +- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: FastAPI-App mit API-Key- und Session-Auth` ### Task 9: CRUD — Konten, Buchungen, Kategorien, Regeln @@ -1310,7 +1310,7 @@ def _placeholder_accounts(): # wird in Task 9 durch Router ersetzt - `services.categorize.apply_rules(session, transactions) -> int` (setzt `category_id` per Regel, Rückgabe: Anzahl kategorisiert; Regel-Match: `rule.pattern.lower() in (purpose + " " + counterparty).lower()`, Regeln nach `priority` aufsteigend, erster Treffer gewinnt). - `services.balances.account_balance(session, account) -> Decimal` (Endsaldo des neuesten bestätigten Statements + Summe bestätigter Buchungen mit `booking_date > period_end`; ohne Statement: Summe aller bestätigten Buchungen). `services.balances.total_balance(session) -> Decimal`. -- [ ] **Step 1: Failing Tests** — `finance/tests/test_crud_api.py`: +- [x] **Step 1: Failing Tests** — `finance/tests/test_crud_api.py`: ```python from datetime import date from decimal import Decimal @@ -1364,9 +1364,9 @@ def test_rules_categorize(client): assert txs[0]["category_id"] == cat["id"] ``` -- [ ] **Step 2: Ausführen — FAIL** +- [x] **Step 2: Ausführen — FAIL** -- [ ] **Step 3: Implementieren** — Router mit Pydantic-Schemas (`BaseModel` je Endpoint, `model_config = ConfigDict(from_attributes=True)`), Services: +- [x] **Step 3: Implementieren** — Router mit Pydantic-Schemas (`BaseModel` je Endpoint, `model_config = ConfigDict(from_attributes=True)`), Services: `finance/app/services/categorize.py`: ```python @@ -1475,7 +1475,7 @@ def create_account(data: AccountIn, session: Session = Depends(get_session)): ``` In `main.py`: Platzhalter-Route `_placeholder_accounts` löschen, stattdessen `app.include_router(accounts.router)` etc. -- [ ] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: CRUD-API Konten/Buchungen/Kategorien/Regeln` +- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: CRUD-API Konten/Buchungen/Kategorien/Regeln` ### Task 10: Import-API (Upload → Vorschau → Bestätigen) @@ -1491,7 +1491,7 @@ In `main.py`: Platzhalter-Route `_placeholder_accounts` löschen, stattdessen `a - `POST /api/imports/{id}/confirm` → alle Drafts mit `is_duplicate=False` auf `confirmed`, Statement auf `confirmed`; `DELETE /api/imports/{id}` → Statement + zugehörige Drafts löschen. - Consumes: `registry.parse_pdf`, `validate.*`, `categorize.apply_rules`. -- [ ] **Step 1: Failing Tests** — `finance/tests/test_import_api.py`: Da echte PDFs in Unit-Tests fehlen, wird `registry.parse_pdf` gemockt: +- [x] **Step 1: Failing Tests** — `finance/tests/test_import_api.py`: Da echte PDFs in Unit-Tests fehlen, wird `registry.parse_pdf` gemockt: ```python from datetime import date from decimal import Decimal @@ -1543,9 +1543,9 @@ def test_upload_preview_confirm(client, fake_parse, tmp_path, monkeypatch): assert len(client.get("/api/transactions", headers=H).json()) == 1 ``` -- [ ] **Step 2: Ausführen — FAIL** · **Step 3: Implementieren** gemäß Interface-Block (Importer-Service + Router; `parse_pdf` in `importer.py` importieren als `from app.parsers.registry import parse_pdf`, damit der Test-Monkeypatch greift). +- [x] **Step 2: Ausführen — FAIL** · **Step 3: Implementieren** gemäß Interface-Block (Importer-Service + Router; `parse_pdf` in `importer.py` importieren als `from app.parsers.registry import parse_pdf`, damit der Test-Monkeypatch greift). -- [ ] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Import-API mit Vorschau, Saldo-Pruefung und Duplikat-Schutz` +- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Import-API mit Vorschau, Saldo-Pruefung und Duplikat-Schutz` ### Task 11: Planungs- & Szenario-API @@ -1559,7 +1559,7 @@ def test_upload_preview_confirm(client, fake_parse, tmp_path, monkeypatch): - Szenarien: `GET/POST /api/scenarios`, `PATCH/DELETE /api/scenarios/{id}`, `POST /api/scenarios/{id}/loans/{loan_id}`, `DELETE` dito, `POST /api/scenarios/{id}/modifiers` + `DELETE /api/scenarios/{id}/modifiers/{mod_id}`. - `POST /api/scenarios/{id}/project?horizon_days=548` → `services.projection_service.run_projection(session, scenario, horizon_days, start_date) -> ProjectionResult`: Startsaldo = `total_balance`; ORM → Plain-Dataclasses; `build_cashflows` + `project` (threshold = `settings.warn_threshold`); alte `ProjectionPoint`s des Szenarios löschen, neue Serie + `ProjectionResult` (upsert) speichern; Response `{low_point_date, low_point_balance, below_zero_date, below_threshold_date, series: [{day, balance}, ...]}`. `start_date` Parameter default: heutiges Datum (`date.today()` nur hier, nicht in der Engine). -- [ ] **Step 1: Failing Test** — `finance/tests/test_planning_api.py`: +- [x] **Step 1: Failing Test** — `finance/tests/test_planning_api.py`: ```python from datetime import date from decimal import Decimal @@ -1610,7 +1610,7 @@ def test_loan_in_scenario_keeps_balance_positive(client, db): assert Decimal(body["low_point_balance"]) > Decimal("0") ``` -- [ ] **Step 2: Ausführen — FAIL** · **Step 3: Implementieren** gemäß Interface-Block. Kern von `projection_service.run_projection`: +- [x] **Step 2: Ausführen — FAIL** · **Step 3: Implementieren** gemäß Interface-Block. Kern von `projection_service.run_projection`: ```python from datetime import date, timedelta from datetime import datetime, timezone @@ -1672,7 +1672,7 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int, return result ``` -- [ ] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Planungs- und Szenario-API mit Projektion und Vorschlaegen` +- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Planungs- und Szenario-API mit Projektion und Vorschlaegen` --- @@ -1688,14 +1688,14 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int, - Produces: HTML-Routen `GET /` (Übersicht), `GET /buchungen` — beide leiten ohne gültige Session auf `/login` um (302), API-Key gilt hier nicht. GUI-Formulare posten auf die bestehenden `/api/...`-Endpunkte (Cookie-Auth greift dort). - Login-Formular aus Task 8 wird durch Template `login.html` ersetzt (gleiche Routen). -- [ ] **Step 1: htmx vendoren** +- [x] **Step 1: htmx vendoren** ```bash mkdir -p /home/wlfb/bin/finance/app/static curl -fsSL https://unpkg.com/htmx.org@2.0.6/dist/htmx.min.js \ -o /home/wlfb/bin/finance/app/static/htmx.min.js ``` -- [ ] **Step 2: Failing Test** — `finance/tests/test_gui.py`: +- [x] **Step 2: Failing Test** — `finance/tests/test_gui.py`: ```python def test_pages_require_login(client): for path in ("/", "/buchungen", "/import", "/planung"): @@ -1713,7 +1713,7 @@ def test_pages_render_after_login(client): ``` (`/import` und `/planung` kommen in Task 13 — Test deckt sie schon ab; bis dahin dürfen die Routen minimal existieren und nur das Template-Gerüst rendern.) -- [ ] **Step 3: Implementieren** — `base.html` mit Navigation (Übersicht / Import / Buchungen / Planung / Grafana-Link auf `http://:8097`, Logout-Button), ``, `style.css` schlicht (max-width, Tabellenstil, rote Negativbeträge `.neg { color: #b00 }`). `index.html`: Gesamtsaldo + Tabelle Konto→Saldo (`services.balances`), Liste der nächsten 10 anstehenden Posten (occurrences der RecurringItems + PlannedItems der nächsten 30 Tage), Warnbox falls neueste `ProjectionResult.below_threshold_date` gesetzt, `