2032 lines
84 KiB
Markdown
2032 lines
84 KiB
Markdown
# Finanzberatungs-Tool — Implementierungsplan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Ziel:** Finanzberatungs-Tool als rootless-Podman-Pod (Postgres + FastAPI-App mit Web-GUI + Grafana) in `~/bin`, plus Claude-Code-Beratungsumgebung in `~/fb`.
|
||
|
||
**Spec:** `/home/wlfb/fb/docs/superpowers/specs/2026-07-17-finanzberatung-design.md` — bei Fragen dort nachlesen.
|
||
|
||
**Architektur:** Eine FastAPI-App liefert REST-API (für Claude Code, Bearer-API-Key) und server-gerenderte Web-GUI (Jinja2 + HTMX, Session-Login). PDF-Auszüge (Volksbank/Raiffeisen, HypoVereinsbank, DKB) werden durch deterministische pdfplumber-Parser mit Saldo-Plausibilisierung und Duplikat-Schutz importiert — immer mit Vorschau + Bestätigung. Eine reine Python-Szenario-Engine rechnet tagesgenaue Liquiditätsvorschauen (wiederkehrende Posten, Einmalposten, Kredite, Kürzungs-Modifikatoren). Grafana wird vollständig provisioniert und liest über SQL-Views.
|
||
|
||
**Tech-Stack:** Python 3.12, FastAPI, SQLAlchemy 2 + Alembic, Postgres 17 (Prod) / SQLite (Tests), pdfplumber, Jinja2 + HTMX, Grafana OSS, rootless Podman + systemd-User-Services.
|
||
|
||
## Globale Vorgaben
|
||
|
||
- Arbeitsverzeichnis ist das Git-Repo `/home/wlfb/bin`; Tool-Code unter `finance/`. Phase 8 schreibt zusätzlich ins Repo `/home/wlfb/fb` (absolute Pfade, dort separat committen).
|
||
- Alle Container-Images mit **gepinnten, unveränderlichen Tags** (kein `:latest`); Vorbild: `example_create_pod_langflow.sh`.
|
||
- Ports: API/GUI `127.0.0.1:8096 → 8000`, Grafana `127.0.0.1:8097 → 3000`, Postgres nur pod-intern.
|
||
- Persistente Daten unter `~/.local/share/finance_pod/` (Bind-Mounts mit `:Z`), systemd-Units in `~/.config/systemd/user/`.
|
||
- Geldbeträge immer `Decimal` (nie float), DB-Typ `Numeric(12, 2)`; positive Beträge = Eingang, negative = Ausgang.
|
||
- Secrets nur in `finance/.env` (gitignored); niemals Secrets, Fixture-PDFs oder echte Kontodaten committen.
|
||
- Sprache der GUI und aller Nutzertexte: Deutsch.
|
||
- Vor jedem Commit: `cd /home/wlfb/bin/finance && python -m pytest -q` muss grün sein (bzw. geskippte Fixture-Tests sind ok).
|
||
- Commit-Messages enden mit `Co-Authored-By:`-Zeile gemäß Harness-Vorgabe.
|
||
|
||
---
|
||
|
||
## Phase 1: Projekt-Gerüst
|
||
|
||
### Task 1: Python-Projekt `finance/` anlegen
|
||
|
||
**Files:**
|
||
- Create: `finance/requirements.txt`, `finance/requirements-dev.txt`, `finance/pytest.ini`, `finance/.gitignore`, `finance/app/__init__.py`, `finance/app/config.py`, `finance/tests/__init__.py`, `finance/tests/test_config.py`
|
||
|
||
**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).
|
||
|
||
- [x] **Step 1: Dateien anlegen**
|
||
|
||
`finance/requirements.txt`:
|
||
```
|
||
fastapi==0.116.1
|
||
uvicorn==0.35.0
|
||
sqlalchemy==2.0.41
|
||
alembic==1.16.2
|
||
psycopg[binary]==3.2.9
|
||
jinja2==3.1.6
|
||
python-multipart==0.0.20
|
||
itsdangerous==2.2.0
|
||
pdfplumber==0.11.7
|
||
```
|
||
|
||
`finance/requirements-dev.txt`:
|
||
```
|
||
-r requirements.txt
|
||
pytest==8.4.1
|
||
httpx==0.28.1
|
||
```
|
||
|
||
Falls eine gepinnte Version bei `pip install` nicht auflösbar ist: auf die nächste verfügbare Patch-Version anheben und im Commit erwähnen.
|
||
|
||
`finance/pytest.ini`:
|
||
```ini
|
||
[pytest]
|
||
testpaths = tests
|
||
```
|
||
|
||
`finance/.gitignore`:
|
||
```
|
||
.env
|
||
__pycache__/
|
||
*.sqlite
|
||
tests/fixtures/*.pdf
|
||
.venv/
|
||
```
|
||
|
||
`finance/app/config.py`:
|
||
```python
|
||
import os
|
||
from dataclasses import dataclass
|
||
from decimal import Decimal
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Settings:
|
||
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
|
||
|
||
|
||
@lru_cache
|
||
def get_settings() -> Settings:
|
||
e = os.environ.get
|
||
return Settings(
|
||
database_url=e("FB_DATABASE_URL", "sqlite:///./fb.sqlite"),
|
||
api_key=e("FB_API_KEY", ""),
|
||
gui_user=e("FB_GUI_USER", "admin"),
|
||
gui_password_hash=e("FB_GUI_PASSWORD_HASH", ""),
|
||
session_secret=e("FB_SESSION_SECRET", "dev-secret"),
|
||
inbox_dir=Path(e("FB_INBOX_DIR", "./inbox")),
|
||
uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")),
|
||
warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")),
|
||
horizon_days=int(e("FB_HORIZON_DAYS", "548")),
|
||
)
|
||
```
|
||
|
||
`finance/tests/test_config.py`:
|
||
```python
|
||
from app.config import get_settings
|
||
|
||
|
||
def test_defaults():
|
||
s = get_settings()
|
||
assert s.horizon_days == 548
|
||
assert s.gui_user == "admin"
|
||
```
|
||
|
||
- [x] **Step 2: venv anlegen, Dependencies installieren, Test ausführen**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin/finance
|
||
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
|
||
.venv/bin/python -m pytest -q
|
||
```
|
||
Erwartet: `1 passed`.
|
||
|
||
- [x] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && git add finance && git commit -m "feat: Projekt-Geruest finance/ mit Config und Test-Setup"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2: Datenmodell
|
||
|
||
### Task 2: SQLAlchemy-Modelle + Alembic
|
||
|
||
**Files:**
|
||
- Create: `finance/app/models/__init__.py`, `finance/app/models/tables.py`, `finance/app/db.py`, `finance/alembic.ini`, `finance/alembic/env.py`, `finance/alembic/script.py.mako` (von alembic init), `finance/tests/conftest.py`, `finance/tests/test_models.py`
|
||
|
||
**Interfaces:**
|
||
- 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"}.
|
||
|
||
- [x] **Step 1: Failing Test schreiben**
|
||
|
||
`finance/tests/conftest.py`:
|
||
```python
|
||
import pytest
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy.pool import StaticPool
|
||
|
||
from app.db import Base
|
||
|
||
|
||
@pytest.fixture
|
||
def db():
|
||
engine = create_engine(
|
||
"sqlite://", poolclass=StaticPool,
|
||
connect_args={"check_same_thread": False},
|
||
)
|
||
Base.metadata.create_all(engine)
|
||
with Session(engine) as session:
|
||
yield session
|
||
```
|
||
|
||
`finance/tests/test_models.py`:
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.models.tables import Account, Statement, Transaction
|
||
|
||
|
||
def test_account_transaction_roundtrip(db):
|
||
acc = Account(bank="DKB", iban="DE02120300000000202051", name="Giro", type="giro")
|
||
db.add(acc)
|
||
db.flush()
|
||
st = Statement(filename="a.pdf", bank="DKB", account_id=acc.id,
|
||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
||
opening_balance=Decimal("100.00"), closing_balance=Decimal("50.00"),
|
||
status="draft")
|
||
db.add(st)
|
||
db.flush()
|
||
tx = Transaction(account_id=acc.id, statement_id=st.id,
|
||
booking_date=date(2026, 6, 3), value_date=date(2026, 6, 3),
|
||
amount=Decimal("-50.00"), purpose="Miete Juni",
|
||
counterparty="Vermieter GmbH", status="draft",
|
||
dedup_hash="abc", is_duplicate=False)
|
||
db.add(tx)
|
||
db.commit()
|
||
assert db.query(Transaction).one().amount == Decimal("-50.00")
|
||
```
|
||
|
||
- [x] **Step 2: Test ausführen — erwartet FAIL** (`ModuleNotFoundError: app.db`)
|
||
|
||
- [x] **Step 3: Implementieren**
|
||
|
||
`finance/app/db.py`:
|
||
```python
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import DeclarativeBase, Session
|
||
|
||
from app.config import get_settings
|
||
|
||
|
||
class Base(DeclarativeBase):
|
||
pass
|
||
|
||
|
||
_engine = None
|
||
|
||
|
||
def get_engine():
|
||
global _engine
|
||
if _engine is None:
|
||
_engine = create_engine(get_settings().database_url)
|
||
return _engine
|
||
|
||
|
||
def get_session():
|
||
with Session(get_engine()) as session:
|
||
yield session
|
||
```
|
||
|
||
`finance/app/models/tables.py` (vollständig):
|
||
```python
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
|
||
from app.db import Base
|
||
|
||
MONEY = Numeric(12, 2)
|
||
|
||
|
||
class Account(Base):
|
||
__tablename__ = "accounts"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
bank: Mapped[str] = mapped_column(String(50))
|
||
iban: Mapped[str] = mapped_column(String(34), unique=True)
|
||
name: Mapped[str] = mapped_column(String(100))
|
||
type: Mapped[str] = mapped_column(String(20), default="giro")
|
||
|
||
|
||
class Category(Base):
|
||
__tablename__ = "categories"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||
|
||
|
||
class CategoryRule(Base):
|
||
__tablename__ = "category_rules"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
pattern: Mapped[str] = mapped_column(String(200))
|
||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
|
||
priority: Mapped[int] = mapped_column(Integer, default=100)
|
||
|
||
|
||
class Statement(Base):
|
||
__tablename__ = "statements"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
filename: Mapped[str] = mapped_column(String(255))
|
||
bank: Mapped[str] = mapped_column(String(50))
|
||
account_id: Mapped[int | None] = mapped_column(ForeignKey("accounts.id"), nullable=True)
|
||
period_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
period_end: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
opening_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
|
||
closing_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
|
||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
|
||
|
||
class Transaction(Base):
|
||
__tablename__ = "transactions"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"))
|
||
statement_id: Mapped[int | None] = mapped_column(ForeignKey("statements.id"), nullable=True)
|
||
booking_date: Mapped[date] = mapped_column(Date)
|
||
value_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||
purpose: Mapped[str] = mapped_column(Text, default="")
|
||
counterparty: Mapped[str] = mapped_column(String(200), default="")
|
||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||
dedup_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||
is_duplicate: Mapped[bool] = mapped_column(Boolean, default=False)
|
||
|
||
|
||
class RecurringItem(Base):
|
||
__tablename__ = "recurring_items"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(200))
|
||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||
rhythm: Mapped[str] = mapped_column(String(20))
|
||
due_day: Mapped[int] = mapped_column(Integer)
|
||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||
|
||
|
||
class PlannedItem(Base):
|
||
__tablename__ = "planned_items"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(200))
|
||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||
due: Mapped[date] = mapped_column(Date)
|
||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||
|
||
|
||
class Loan(Base):
|
||
__tablename__ = "loans"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(200))
|
||
principal: Mapped[Decimal] = mapped_column(MONEY)
|
||
annual_rate_pct: Mapped[Decimal] = mapped_column(Numeric(5, 2))
|
||
term_months: Mapped[int] = mapped_column(Integer)
|
||
payout_date: Mapped[date] = mapped_column(Date)
|
||
repayment_type: Mapped[str] = mapped_column(String(20), default="annuity")
|
||
|
||
|
||
class Scenario(Base):
|
||
__tablename__ = "scenarios"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
name: Mapped[str] = mapped_column(String(200), unique=True)
|
||
description: Mapped[str] = mapped_column(Text, default="")
|
||
include_recurring: Mapped[bool] = mapped_column(Boolean, default=True)
|
||
include_planned: Mapped[bool] = mapped_column(Boolean, default=True)
|
||
|
||
|
||
class ScenarioLoan(Base):
|
||
__tablename__ = "scenario_loans"
|
||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), primary_key=True)
|
||
loan_id: Mapped[int] = mapped_column(ForeignKey("loans.id"), primary_key=True)
|
||
|
||
|
||
class ScenarioModifier(Base):
|
||
__tablename__ = "scenario_modifiers"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"))
|
||
target_type: Mapped[str] = mapped_column(String(20))
|
||
target_id: Mapped[int] = mapped_column(Integer)
|
||
kind: Mapped[str] = mapped_column(String(20))
|
||
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
|
||
|
||
|
||
class ProjectionPoint(Base):
|
||
__tablename__ = "projection_points"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
|
||
day: Mapped[date] = mapped_column(Date)
|
||
balance: Mapped[Decimal] = mapped_column(MONEY)
|
||
|
||
|
||
class ProjectionResult(Base):
|
||
__tablename__ = "projection_results"
|
||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), primary_key=True)
|
||
computed_at: Mapped[datetime] = mapped_column(DateTime)
|
||
low_point_date: Mapped[date] = mapped_column(Date)
|
||
low_point_balance: Mapped[Decimal] = mapped_column(MONEY)
|
||
below_zero_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
below_threshold_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
```
|
||
|
||
`finance/app/models/__init__.py`:
|
||
```python
|
||
from app.models import tables # noqa: F401
|
||
```
|
||
|
||
- [x] **Step 4: Test ausführen — erwartet PASS** (`.venv/bin/python -m pytest -q`)
|
||
|
||
- [x] **Step 5: Alembic initialisieren**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin/finance
|
||
.venv/bin/alembic init alembic
|
||
```
|
||
In `alembic/env.py` ergänzen (vor `run_migrations_offline`):
|
||
```python
|
||
from app.config import get_settings
|
||
from app.db import Base
|
||
import app.models # noqa: F401
|
||
|
||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||
target_metadata = Base.metadata
|
||
```
|
||
Dann initiale Migration erzeugen und lokal gegen SQLite prüfen:
|
||
```bash
|
||
FB_DATABASE_URL=sqlite:///./mig_test.sqlite .venv/bin/alembic revision --autogenerate -m "initial schema"
|
||
FB_DATABASE_URL=sqlite:///./mig_test.sqlite .venv/bin/alembic upgrade head
|
||
rm mig_test.sqlite
|
||
```
|
||
Erwartet: Migration in `alembic/versions/` erzeugt, `upgrade head` läuft fehlerfrei.
|
||
|
||
- [x] **Step 6: Commit** — `git add finance && git commit -m "feat: Datenmodell (SQLAlchemy) und Alembic-Migration"`
|
||
|
||
---
|
||
|
||
## Phase 3: Szenario-Engine (reines Python, keine DB)
|
||
|
||
### Task 3: Kredit-Mathematik
|
||
|
||
**Files:**
|
||
- Create: `finance/app/engine/__init__.py`, `finance/app/engine/loans.py`, `finance/tests/test_loans.py`
|
||
|
||
**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).
|
||
|
||
- [x] **Step 1: Failing Tests schreiben**
|
||
|
||
`finance/tests/test_loans.py`:
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.engine.loans import add_months, annuity_payment, loan_schedule
|
||
|
||
|
||
def test_add_months_clamps_month_end():
|
||
assert add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)
|
||
|
||
|
||
def test_annuity_payment():
|
||
# 10.000 EUR, 6 % p.a., 48 Monate -> 234,85 EUR Monatsrate
|
||
assert annuity_payment(Decimal("10000"), Decimal("6"), 48) == Decimal("234.85")
|
||
|
||
|
||
def test_annuity_schedule_amortizes_fully():
|
||
plan = loan_schedule(Decimal("10000"), Decimal("6"), 48, date(2026, 8, 1), "annuity")
|
||
assert len(plan) == 48
|
||
assert plan[-1].remaining == Decimal("0.00")
|
||
assert plan[0].due == date(2026, 9, 1)
|
||
assert plan[0].interest == Decimal("50.00") # 10000 * 0,5 %
|
||
assert plan[0].payment < 0
|
||
|
||
|
||
def test_bullet_loan():
|
||
plan = loan_schedule(Decimal("10000"), Decimal("6"), 12, date(2026, 8, 1), "bullet")
|
||
assert all(i.principal == 0 for i in plan[:-1])
|
||
assert plan[-1].principal == Decimal("10000")
|
||
assert plan[-1].remaining == Decimal("0.00")
|
||
```
|
||
|
||
- [x] **Step 2: Ausführen — erwartet FAIL** (Modul fehlt)
|
||
|
||
- [x] **Step 3: Implementieren**
|
||
|
||
`finance/app/engine/loans.py`:
|
||
```python
|
||
import calendar
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from decimal import ROUND_HALF_UP, Decimal
|
||
|
||
CENT = Decimal("0.01")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Installment:
|
||
due: date
|
||
payment: Decimal
|
||
interest: Decimal
|
||
principal: Decimal
|
||
remaining: Decimal
|
||
|
||
|
||
def add_months(d: date, months: int) -> date:
|
||
y, m0 = divmod(d.year * 12 + d.month - 1 + months, 12)
|
||
m = m0 + 1
|
||
return date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
|
||
|
||
|
||
def annuity_payment(principal: Decimal, annual_rate_pct: Decimal, term_months: int) -> Decimal:
|
||
i = annual_rate_pct / Decimal(100) / Decimal(12)
|
||
if i == 0:
|
||
return (principal / term_months).quantize(CENT, ROUND_HALF_UP)
|
||
q = (Decimal(1) + i) ** term_months
|
||
return (principal * i * q / (q - Decimal(1))).quantize(CENT, ROUND_HALF_UP)
|
||
|
||
|
||
def loan_schedule(principal: Decimal, annual_rate_pct: Decimal, term_months: int,
|
||
payout_date: date, repayment_type: str) -> list[Installment]:
|
||
i = annual_rate_pct / Decimal(100) / Decimal(12)
|
||
plan: list[Installment] = []
|
||
remaining = principal
|
||
if repayment_type == "bullet":
|
||
for n in range(1, term_months + 1):
|
||
interest = (remaining * i).quantize(CENT, ROUND_HALF_UP)
|
||
principal_part = remaining if n == term_months else Decimal("0")
|
||
remaining = remaining - principal_part
|
||
plan.append(Installment(add_months(payout_date, n),
|
||
-(interest + principal_part), interest,
|
||
principal_part, remaining.quantize(CENT)))
|
||
return plan
|
||
rate = annuity_payment(principal, annual_rate_pct, term_months)
|
||
for n in range(1, term_months + 1):
|
||
interest = (remaining * i).quantize(CENT, ROUND_HALF_UP)
|
||
principal_part = rate - interest
|
||
if n == term_months or principal_part > remaining:
|
||
principal_part = remaining
|
||
remaining = remaining - principal_part
|
||
plan.append(Installment(add_months(payout_date, n),
|
||
-(interest + principal_part), interest,
|
||
principal_part, remaining.quantize(CENT)))
|
||
return plan
|
||
```
|
||
|
||
- [x] **Step 4: Ausführen — erwartet PASS**
|
||
|
||
- [x] **Step 5: Commit** — `feat: Kredit-Mathematik (Annuitaet, endfaellig)`
|
||
|
||
### Task 4: Wiederholungs-Termine (Recurrence)
|
||
|
||
**Files:**
|
||
- Create: `finance/app/engine/recurrence.py`, `finance/tests/test_recurrence.py`
|
||
|
||
**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]`.
|
||
|
||
- [x] **Step 1: Failing Tests**
|
||
|
||
`finance/tests/test_recurrence.py`:
|
||
```python
|
||
from datetime import date
|
||
|
||
from app.engine.recurrence import occurrences
|
||
|
||
|
||
def test_monthly_clamps_short_months():
|
||
got = occurrences("monthly", 31, date(2026, 1, 1), date(2026, 3, 31))
|
||
assert got == [date(2026, 1, 31), date(2026, 2, 28), date(2026, 3, 31)]
|
||
|
||
|
||
def test_quarterly_respects_item_bounds():
|
||
got = occurrences("quarterly", 15, date(2026, 1, 1), date(2026, 12, 31),
|
||
item_start=date(2026, 4, 1), item_end=date(2026, 10, 31))
|
||
assert got == [date(2026, 4, 15), date(2026, 7, 15), date(2026, 10, 15)]
|
||
|
||
|
||
def test_yearly():
|
||
got = occurrences("yearly", 1, date(2026, 1, 1), date(2028, 12, 31))
|
||
assert got == [date(2026, 1, 1), date(2027, 1, 1), date(2028, 1, 1)]
|
||
```
|
||
|
||
- [x] **Step 2: Ausführen — FAIL**
|
||
|
||
- [x] **Step 3: Implementieren**
|
||
|
||
`finance/app/engine/recurrence.py`:
|
||
```python
|
||
import calendar
|
||
from datetime import date
|
||
|
||
STEP = {"monthly": 1, "quarterly": 3, "yearly": 12}
|
||
|
||
|
||
def _clamp(y: int, m: int, day: int) -> date:
|
||
return date(y, m, min(day, calendar.monthrange(y, m)[1]))
|
||
|
||
|
||
def 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 = STEP[rhythm]
|
||
lo = max(window_start, item_start) if item_start else window_start
|
||
hi = min(window_end, item_end) if item_end else window_end
|
||
anchor = item_start or window_start
|
||
d = _clamp(anchor.year, anchor.month, due_day)
|
||
out: list[date] = []
|
||
while d <= hi:
|
||
if d >= lo:
|
||
out.append(d)
|
||
total = d.year * 12 + d.month - 1 + step
|
||
y, m0 = divmod(total, 12)
|
||
d = _clamp(y, m0 + 1, due_day)
|
||
return out
|
||
```
|
||
|
||
- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Recurrence-Terminberechnung`
|
||
|
||
### Task 5: Projektion, Modifikatoren, Szenario-Zahlungsstrom
|
||
|
||
**Files:**
|
||
- Create: `finance/app/engine/projection.py`, `finance/app/engine/scenario.py`, `finance/tests/test_projection.py`, `finance/tests/test_scenario_engine.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `projection.project(start_balance: Decimal, start_date: date, cashflows: list[tuple[date, Decimal]], horizon_days: int, threshold: Decimal = Decimal("0")) -> Projection` mit `Projection(series: list[tuple[date, Decimal]], low_point: tuple[date, Decimal], first_below_zero: date | None, first_below_threshold: date | None)`.
|
||
- `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]]`.
|
||
|
||
- [x] **Step 1: Failing Tests**
|
||
|
||
`finance/tests/test_projection.py`:
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.engine.projection import project
|
||
|
||
|
||
def test_low_point_and_threshold():
|
||
flows = [(date(2026, 8, 1), Decimal("-150")), (date(2026, 8, 10), Decimal("200"))]
|
||
p = project(Decimal("100"), date(2026, 7, 31), flows, horizon_days=15,
|
||
threshold=Decimal("60"))
|
||
assert p.low_point == (date(2026, 8, 1), Decimal("-50"))
|
||
assert p.first_below_zero == date(2026, 8, 1)
|
||
assert p.first_below_threshold == date(2026, 8, 1)
|
||
assert p.series[-1][1] == Decimal("150")
|
||
|
||
|
||
def test_no_crossing():
|
||
p = project(Decimal("100"), date(2026, 7, 31), [], horizon_days=5)
|
||
assert p.first_below_zero is None
|
||
assert p.low_point[1] == Decimal("100")
|
||
```
|
||
|
||
`finance/tests/test_scenario_engine.py`:
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.engine.loans import loan_schedule
|
||
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
|
||
build_cashflows)
|
||
|
||
W = (date(2026, 8, 1), date(2026, 10, 31))
|
||
|
||
|
||
def test_recurring_with_percent_cut():
|
||
rec = [PlainRecurring(id=1, name="Marketing", amount=Decimal("-1000"),
|
||
rhythm="monthly", due_day=5, start_date=None,
|
||
end_date=None, category_id=7)]
|
||
mods = [PlainModifier(target_type="category", target_id=7,
|
||
kind="percent", value=Decimal("50"))]
|
||
flows = build_cashflows(rec, [], [], [], mods, *W)
|
||
assert flows == [(date(2026, 8, 5), Decimal("-500.00")),
|
||
(date(2026, 9, 5), Decimal("-500.00")),
|
||
(date(2026, 10, 5), Decimal("-500.00"))]
|
||
|
||
|
||
def test_remove_modifier_and_planned_and_loan():
|
||
rec = [PlainRecurring(id=2, name="Abo", amount=Decimal("-50"),
|
||
rhythm="monthly", due_day=1, start_date=None,
|
||
end_date=None, category_id=None)]
|
||
mods = [PlainModifier(target_type="recurring", target_id=2,
|
||
kind="remove", value=Decimal("0"))]
|
||
planned = [PlainPlanned(name="Steuer", amount=Decimal("-2000"),
|
||
due=date(2026, 9, 15), category_id=None)]
|
||
sched = loan_schedule(Decimal("10000"), Decimal("6"), 48, date(2026, 8, 1), "annuity")
|
||
flows = build_cashflows(rec, planned, [sched],
|
||
[(date(2026, 8, 1), Decimal("10000"))], mods, *W)
|
||
days = [d for d, _ in flows]
|
||
assert date(2026, 8, 1) in days # Kredit-Auszahlung
|
||
assert date(2026, 9, 15) in days # Einmalposten
|
||
assert all(a != Decimal("-50") for _, a in flows) # Abo entfernt
|
||
```
|
||
|
||
- [x] **Step 2: Ausführen — FAIL**
|
||
|
||
- [x] **Step 3: Implementieren**
|
||
|
||
`finance/app/engine/projection.py`:
|
||
```python
|
||
from collections import defaultdict
|
||
from dataclasses import dataclass
|
||
from datetime import date, timedelta
|
||
from decimal import Decimal
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Projection:
|
||
series: list[tuple[date, Decimal]]
|
||
low_point: tuple[date, Decimal]
|
||
first_below_zero: date | None
|
||
first_below_threshold: date | None
|
||
|
||
|
||
def project(start_balance: Decimal, start_date: date,
|
||
cashflows: list[tuple[date, Decimal]], horizon_days: int,
|
||
threshold: Decimal = Decimal("0")) -> Projection:
|
||
end = start_date + timedelta(days=horizon_days)
|
||
by_day: dict[date, Decimal] = defaultdict(lambda: Decimal("0"))
|
||
for d, amount in cashflows:
|
||
if start_date < d <= end:
|
||
by_day[d] += amount
|
||
series: list[tuple[date, Decimal]] = []
|
||
balance = start_balance
|
||
low = (start_date, start_balance)
|
||
below0: date | None = None
|
||
below_t: date | None = None
|
||
d = start_date + timedelta(days=1)
|
||
while d <= end:
|
||
balance += by_day.get(d, Decimal("0"))
|
||
series.append((d, balance))
|
||
if balance < low[1]:
|
||
low = (d, balance)
|
||
if below0 is None and balance < 0:
|
||
below0 = d
|
||
if below_t is None and balance < threshold:
|
||
below_t = d
|
||
d += timedelta(days=1)
|
||
return Projection(series, low, below0, below_t)
|
||
```
|
||
|
||
`finance/app/engine/scenario.py`:
|
||
```python
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from decimal import ROUND_HALF_UP, Decimal
|
||
|
||
from app.engine.loans import Installment
|
||
from app.engine.recurrence import occurrences
|
||
|
||
CENT = Decimal("0.01")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PlainRecurring:
|
||
id: int
|
||
name: str
|
||
amount: Decimal
|
||
rhythm: str
|
||
due_day: int
|
||
start_date: date | None
|
||
end_date: date | None
|
||
category_id: int | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PlainPlanned:
|
||
name: str
|
||
amount: Decimal
|
||
due: date
|
||
category_id: int | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PlainModifier:
|
||
target_type: str
|
||
target_id: int
|
||
kind: str
|
||
value: Decimal
|
||
|
||
|
||
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||
modifiers: list[PlainModifier]) -> Decimal | None:
|
||
for m in modifiers:
|
||
hit = ((m.target_type == "category" and category_id == m.target_id)
|
||
or (m.target_type == "recurring" and recurring_id == m.target_id))
|
||
if not hit:
|
||
continue
|
||
if m.kind == "remove":
|
||
return None
|
||
if m.kind == "percent":
|
||
amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP)
|
||
elif m.kind == "absolute":
|
||
if amount < 0:
|
||
amount = min(Decimal("0"), amount + m.value)
|
||
else:
|
||
amount = max(Decimal("0"), amount - m.value)
|
||
return amount
|
||
|
||
|
||
def 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]]:
|
||
flows: list[tuple[date, Decimal]] = []
|
||
for r in recurring:
|
||
amount = _modified(r.amount, r.category_id, r.id, modifiers)
|
||
if amount is None:
|
||
continue
|
||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||
r.start_date, r.end_date):
|
||
flows.append((d, amount))
|
||
for p in planned:
|
||
amount = _modified(p.amount, p.category_id, None, modifiers)
|
||
if amount is not None and window_start <= p.due <= window_end:
|
||
flows.append((p.due, amount))
|
||
for d, payout in loan_payouts:
|
||
if window_start <= d <= window_end:
|
||
flows.append((d, payout))
|
||
for sched in loan_schedules:
|
||
for inst in sched:
|
||
if window_start <= inst.due <= window_end:
|
||
flows.append((inst.due, inst.payment))
|
||
return sorted(flows)
|
||
```
|
||
|
||
- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Szenario-Engine (Projektion, Modifikatoren, Zahlungsstrom)`
|
||
|
||
---
|
||
|
||
## Phase 4: PDF-Parser
|
||
|
||
### Task 6: Parser-Basis, Bank-Erkennung, Validierung
|
||
|
||
**Files:**
|
||
- Create: `finance/app/parsers/__init__.py`, `finance/app/parsers/base.py`, `finance/app/parsers/detect.py`, `finance/app/parsers/validate.py`, `finance/tests/test_parser_base.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `base.ParsedTransaction(booking_date: date, value_date: date | None, amount: Decimal, purpose: str, counterparty: str)`; `base.ParsedStatement(bank: str, iban: str | None, period_start, period_end, opening_balance, closing_balance, transactions: list[ParsedTransaction])`; `base.ParserError(Exception)`; `base.parse_german_amount(s: str) -> Decimal`; `base.parse_german_date(s: str, default_year: int | None = None) -> date`.
|
||
- `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`.
|
||
|
||
- [x] **Step 1: Failing Tests**
|
||
|
||
`finance/tests/test_parser_base.py`:
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.parsers.base import (ParsedStatement, ParsedTransaction,
|
||
parse_german_amount, parse_german_date)
|
||
from app.parsers.detect import detect_bank
|
||
from app.parsers.validate import balance_difference, dedup_hash
|
||
|
||
|
||
def test_parse_german_amount():
|
||
assert parse_german_amount("1.234,56") == Decimal("1234.56")
|
||
assert parse_german_amount("1.234,56-") == Decimal("-1234.56")
|
||
assert parse_german_amount("12,00 S") == Decimal("-12.00")
|
||
assert parse_german_amount("12,00 H") == Decimal("12.00")
|
||
|
||
|
||
def test_parse_german_date():
|
||
assert parse_german_date("03.06.2026") == date(2026, 6, 3)
|
||
assert parse_german_date("03.06.", default_year=2026) == date(2026, 6, 3)
|
||
|
||
|
||
def test_detect_bank():
|
||
assert detect_bank("... Volksbank Musterstadt eG ...") == "vr"
|
||
assert detect_bank("... Raiffeisenbank ...") == "vr"
|
||
assert detect_bank("... HypoVereinsbank ...") == "hvb"
|
||
assert detect_bank("... UniCredit Bank ...") == "hvb"
|
||
assert detect_bank("... Deutsche Kreditbank AG ...") == "dkb"
|
||
assert detect_bank("... DKB ...") == "dkb"
|
||
assert detect_bank("irgendwas") is None
|
||
|
||
|
||
def _stmt(txs, opening, closing):
|
||
return ParsedStatement(bank="vr", iban=None, period_start=None,
|
||
period_end=None, opening_balance=opening,
|
||
closing_balance=closing, transactions=txs)
|
||
|
||
|
||
def test_balance_difference():
|
||
txs = [ParsedTransaction(date(2026, 6, 1), None, Decimal("-50"), "Miete", "V"),
|
||
ParsedTransaction(date(2026, 6, 2), None, Decimal("20"), "Gutschrift", "K")]
|
||
assert balance_difference(_stmt(txs, Decimal("100"), Decimal("70"))) == Decimal("0")
|
||
assert balance_difference(_stmt(txs, Decimal("100"), Decimal("75"))) == Decimal("5")
|
||
|
||
|
||
def test_dedup_hash_normalizes_whitespace():
|
||
a = dedup_hash(1, date(2026, 6, 1), Decimal("-50.00"), "Miete Juni")
|
||
b = dedup_hash(1, date(2026, 6, 1), Decimal("-50.00"), "Miete Juni")
|
||
assert a == b and len(a) == 64
|
||
```
|
||
|
||
- [x] **Step 2: Ausführen — FAIL**
|
||
|
||
- [x] **Step 3: Implementieren**
|
||
|
||
`finance/app/parsers/base.py`:
|
||
```python
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
|
||
class ParserError(Exception):
|
||
pass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ParsedTransaction:
|
||
booking_date: date
|
||
value_date: date | None
|
||
amount: Decimal
|
||
purpose: str
|
||
counterparty: str
|
||
|
||
|
||
@dataclass
|
||
class ParsedStatement:
|
||
bank: str
|
||
iban: str | None
|
||
period_start: date | None
|
||
period_end: date | None
|
||
opening_balance: Decimal | None
|
||
closing_balance: Decimal | None
|
||
transactions: list[ParsedTransaction] = field(default_factory=list)
|
||
|
||
|
||
def parse_german_amount(s: str) -> Decimal:
|
||
s = s.strip().replace(" ", "").replace(" ", "")
|
||
neg = s.endswith("-") or s.startswith("-") or s.endswith("S")
|
||
s = s.strip("+-SH")
|
||
value = Decimal(s.replace(".", "").replace(",", "."))
|
||
return -value if neg else value
|
||
|
||
|
||
def parse_german_date(s: str, default_year: int | None = None) -> date:
|
||
m = re.fullmatch(r"(\d{2})\.(\d{2})\.(\d{4})?", s.strip())
|
||
if not m:
|
||
raise ParserError(f"Kein Datum: {s!r}")
|
||
year = int(m.group(3)) if m.group(3) else default_year
|
||
if year is None:
|
||
raise ParserError(f"Jahr fehlt: {s!r}")
|
||
return date(year, int(m.group(2)), int(m.group(1)))
|
||
```
|
||
|
||
`finance/app/parsers/detect.py`:
|
||
```python
|
||
MARKERS = [
|
||
("vr", ["Volksbank", "Raiffeisenbank", "VR-Bank", "VR Bank"]),
|
||
("hvb", ["HypoVereinsbank", "UniCredit"]),
|
||
("dkb", ["Deutsche Kreditbank", "DKB"]),
|
||
]
|
||
|
||
|
||
def detect_bank(text: str) -> str | None:
|
||
for bank, needles in MARKERS:
|
||
if any(n in text for n in needles):
|
||
return bank
|
||
return None
|
||
```
|
||
|
||
`finance/app/parsers/validate.py`:
|
||
```python
|
||
import hashlib
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.parsers.base import ParsedStatement
|
||
|
||
|
||
def balance_difference(p: ParsedStatement) -> Decimal:
|
||
total = sum((t.amount for t in p.transactions), Decimal("0"))
|
||
return (p.opening_balance + total - p.closing_balance).copy_abs()
|
||
|
||
|
||
def dedup_hash(account_id: int, booking_date: date, amount: Decimal, purpose: str) -> str:
|
||
raw = f"{account_id}|{booking_date.isoformat()}|{amount:.2f}|{' '.join(purpose.split())}"
|
||
return hashlib.sha256(raw.encode()).hexdigest()
|
||
```
|
||
|
||
- [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)
|
||
|
||
**Files:**
|
||
- Create: `finance/app/parsers/vr.py`, `finance/app/parsers/hvb.py`, `finance/app/parsers/dkb.py`, `finance/app/parsers/registry.py`, `finance/tests/test_bank_parsers.py`, `finance/tests/fixtures/README.md`
|
||
|
||
**Interfaces:**
|
||
- Produces: je Modul `parse(path: Path) -> ParsedStatement`; `registry.PARSERS: dict[str, Callable[[Path], ParsedStatement]]` mit Schlüsseln `"vr", "hvb", "dkb"`; `registry.parse_pdf(path: Path) -> ParsedStatement` (extrahiert Text, erkennt Bank via `detect_bank`, ruft Parser; `ParserError` bei unbekannter Bank).
|
||
|
||
**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.
|
||
|
||
- [x] **Step 1: Fixture-README und Tests schreiben**
|
||
|
||
`finance/tests/fixtures/README.md`:
|
||
```markdown
|
||
# Beispiel-Auszüge (nicht committen!)
|
||
|
||
Hier pro Bank mindestens einen echten PDF-Kontoauszug ablegen:
|
||
- `vr_beispiel.pdf` (Volksbank/Raiffeisenbank)
|
||
- `hvb_beispiel.pdf` (HypoVereinsbank)
|
||
- `dkb_beispiel.pdf` (DKB)
|
||
|
||
Beträge dürfen verfremdet sein, das Layout muss echt sein.
|
||
PDFs sind via .gitignore vom Repo ausgeschlossen.
|
||
```
|
||
|
||
`finance/tests/test_bank_parsers.py`:
|
||
```python
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app.parsers.registry import parse_pdf
|
||
from app.parsers.validate import balance_difference
|
||
|
||
FIXTURES = Path(__file__).parent / "fixtures"
|
||
CASES = [("vr_beispiel.pdf", "vr"), ("hvb_beispiel.pdf", "hvb"),
|
||
("dkb_beispiel.pdf", "dkb")]
|
||
|
||
|
||
@pytest.mark.parametrize("filename,bank", CASES)
|
||
def test_parse_fixture(filename, bank):
|
||
path = FIXTURES / filename
|
||
if not path.exists():
|
||
pytest.skip(f"Fixture {filename} fehlt")
|
||
stmt = parse_pdf(path)
|
||
assert stmt.bank == bank
|
||
assert stmt.opening_balance is not None
|
||
assert stmt.closing_balance is not None
|
||
assert len(stmt.transactions) > 0
|
||
assert balance_difference(stmt) == Decimal("0")
|
||
```
|
||
|
||
- [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
|
||
"""Parser für Volksbank/Raiffeisenbank-Kontoauszüge.
|
||
|
||
Die REGEX-Konstanten unten sind das Einzige, was beim Abgleich mit den
|
||
echten Fixture-PDFs angepasst werden muss.
|
||
"""
|
||
import re
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
import pdfplumber
|
||
|
||
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
|
||
parse_german_amount, parse_german_date)
|
||
|
||
BANK = "vr"
|
||
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
|
||
# "Kontoauszug 6/2026" oder "vom 01.06.2026 bis 30.06.2026"
|
||
RE_PERIOD = re.compile(r"vom\s+(\d{2}\.\d{2}\.\d{4})\s+bis\s+(\d{2}\.\d{2}\.\d{4})")
|
||
# "alter Kontostand 1.234,56 H" / "neuer Kontostand 987,65 H"
|
||
RE_OPENING = re.compile(r"alter\s+Kontostand\s+([\d.,]+\s*[SH-]?)", re.I)
|
||
RE_CLOSING = re.compile(r"neuer\s+Kontostand\s+([\d.,]+\s*[SH-]?)", re.I)
|
||
# Buchungszeile: "01.06. 01.06. <Text> 1.234,56 S"
|
||
RE_TX = re.compile(r"^(\d{2}\.\d{2}\.)\s+(\d{2}\.\d{2}\.)\s+(.*?)\s+([\d.,]+\s*[SH-])$")
|
||
|
||
|
||
def parse(path: Path) -> ParsedStatement:
|
||
with pdfplumber.open(path) as pdf:
|
||
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
|
||
period = RE_PERIOD.search(text)
|
||
if not period:
|
||
raise ParserError("VR: Abrechnungszeitraum nicht gefunden")
|
||
period_start = parse_german_date(period.group(1))
|
||
period_end = parse_german_date(period.group(2))
|
||
opening = RE_OPENING.search(text)
|
||
closing = RE_CLOSING.search(text)
|
||
if not opening or not closing:
|
||
raise ParserError("VR: Anfangs-/Endsaldo nicht gefunden")
|
||
iban_m = RE_IBAN.search(text)
|
||
txs: list[ParsedTransaction] = []
|
||
pending: dict | None = None
|
||
for line in text.splitlines():
|
||
m = RE_TX.match(line.strip())
|
||
if m:
|
||
if pending:
|
||
txs.append(_finish(pending))
|
||
pending = {
|
||
"booking": parse_german_date(m.group(1), period_end.year),
|
||
"value": parse_german_date(m.group(2), period_end.year),
|
||
"head": m.group(3).strip(),
|
||
"amount": parse_german_amount(m.group(4)),
|
||
"extra": [],
|
||
}
|
||
elif pending and line.strip() and not RE_CLOSING.search(line):
|
||
pending["extra"].append(line.strip())
|
||
if pending:
|
||
txs.append(_finish(pending))
|
||
return ParsedStatement(bank=BANK,
|
||
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
|
||
period_start=period_start, period_end=period_end,
|
||
opening_balance=parse_german_amount(opening.group(1)),
|
||
closing_balance=parse_german_amount(closing.group(1)),
|
||
transactions=txs)
|
||
|
||
|
||
def _finish(p: dict) -> ParsedTransaction:
|
||
counterparty = p["extra"][0] if p["extra"] else ""
|
||
purpose = " ".join([p["head"], *p["extra"][1:]]).strip()
|
||
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
|
||
amount=p["amount"], purpose=purpose,
|
||
counterparty=counterparty)
|
||
```
|
||
|
||
`finance/app/parsers/registry.py`:
|
||
```python
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
import pdfplumber
|
||
|
||
from app.parsers import dkb, hvb, vr
|
||
from app.parsers.base import ParsedStatement, ParserError
|
||
from app.parsers.detect import detect_bank
|
||
|
||
PARSERS: dict[str, Callable[[Path], ParsedStatement]] = {
|
||
"vr": vr.parse, "hvb": hvb.parse, "dkb": dkb.parse,
|
||
}
|
||
|
||
|
||
def parse_pdf(path: Path) -> ParsedStatement:
|
||
with pdfplumber.open(path) as pdf:
|
||
first = pdf.pages[0].extract_text() or ""
|
||
bank = detect_bank(first)
|
||
if bank is None:
|
||
raise ParserError("Bank nicht erkannt")
|
||
return PARSERS[bank](path)
|
||
```
|
||
|
||
- [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 <pdf>"""
|
||
import sys
|
||
|
||
import pdfplumber
|
||
|
||
with pdfplumber.open(sys.argv[1]) as pdf:
|
||
for i, page in enumerate(pdf.pages, 1):
|
||
print(f"--- Seite {i} ---")
|
||
print(page.extract_text() or "(kein Text)")
|
||
```
|
||
|
||
- [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.
|
||
|
||
- [x] **Step 5: Alle Tests ausführen — PASS/SKIP** · **Step 6: Commit** — `feat: PDF-Parser VR/HVB/DKB mit Registry und Fixture-Tests`
|
||
|
||
---
|
||
|
||
## Phase 5: API
|
||
|
||
### Task 8: FastAPI-App, Auth (API-Key + Session-Login)
|
||
|
||
**Files:**
|
||
- Create: `finance/app/main.py`, `finance/app/auth.py`, `finance/tests/test_auth.py`, `finance/tests/conftest.py` (erweitern)
|
||
|
||
**Interfaces:**
|
||
- 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 <FB_API_KEY>` **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`.
|
||
|
||
- [x] **Step 1: conftest erweitern + Failing Tests**
|
||
|
||
In `finance/tests/conftest.py` ergänzen:
|
||
```python
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.db import get_session
|
||
|
||
|
||
@pytest.fixture
|
||
def client(db, monkeypatch):
|
||
monkeypatch.setenv("FB_API_KEY", "test-key")
|
||
from app.config import get_settings
|
||
get_settings.cache_clear()
|
||
from app.auth import hash_password
|
||
monkeypatch.setenv("FB_GUI_PASSWORD_HASH", hash_password("geheim"))
|
||
get_settings.cache_clear()
|
||
from app.main import app
|
||
app.dependency_overrides[get_session] = lambda: iter([db])
|
||
with TestClient(app) as c:
|
||
yield c
|
||
app.dependency_overrides.clear()
|
||
get_settings.cache_clear()
|
||
```
|
||
|
||
`finance/tests/test_auth.py`:
|
||
```python
|
||
def test_api_requires_key(client):
|
||
assert client.get("/api/accounts").status_code == 401
|
||
r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"})
|
||
assert r.status_code == 200
|
||
|
||
|
||
def test_login_flow(client):
|
||
r = client.post("/login", data={"username": "admin", "password": "falsch"},
|
||
follow_redirects=False)
|
||
assert r.status_code == 401
|
||
r = client.post("/login", data={"username": "admin", "password": "geheim"},
|
||
follow_redirects=False)
|
||
assert r.status_code == 303
|
||
assert client.get("/api/accounts").status_code == 200 # Cookie reicht
|
||
```
|
||
|
||
- [x] **Step 2: Ausführen — FAIL**
|
||
|
||
- [x] **Step 3: Implementieren**
|
||
|
||
`finance/app/auth.py`:
|
||
```python
|
||
import hashlib
|
||
import hmac
|
||
import secrets
|
||
|
||
from fastapi import HTTPException, Request
|
||
from itsdangerous import BadSignature, TimestampSigner
|
||
|
||
from app.config import get_settings
|
||
|
||
COOKIE = "fb_session"
|
||
MAX_AGE = 60 * 60 * 12 # 12 h
|
||
|
||
|
||
def hash_password(pw: str, salt: str | None = None) -> str:
|
||
salt = salt or secrets.token_hex(16)
|
||
digest = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 200_000).hex()
|
||
return f"{salt}${digest}"
|
||
|
||
|
||
def verify_password(pw: str, stored: str) -> bool:
|
||
try:
|
||
salt, digest = stored.split("$", 1)
|
||
except ValueError:
|
||
return False
|
||
return hmac.compare_digest(hash_password(pw, salt).split("$", 1)[1], digest)
|
||
|
||
|
||
def _signer() -> TimestampSigner:
|
||
return TimestampSigner(get_settings().session_secret)
|
||
|
||
|
||
def make_session_token() -> str:
|
||
return _signer().sign(b"gui").decode()
|
||
|
||
|
||
def session_valid(token: str | None) -> bool:
|
||
if not token:
|
||
return False
|
||
try:
|
||
_signer().unsign(token, max_age=MAX_AGE)
|
||
return True
|
||
except BadSignature:
|
||
return False
|
||
|
||
|
||
def require_auth(request: Request) -> None:
|
||
settings = get_settings()
|
||
header = request.headers.get("authorization", "")
|
||
if settings.api_key and header == f"Bearer {settings.api_key}":
|
||
return
|
||
if session_valid(request.cookies.get(COOKIE)):
|
||
return
|
||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||
```
|
||
|
||
`finance/app/main.py`:
|
||
```python
|
||
import hmac
|
||
|
||
from fastapi import Depends, FastAPI, Form, Request
|
||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||
|
||
from app import auth
|
||
from app.config import get_settings
|
||
|
||
app = FastAPI(title="Finanzberatungs-Tool")
|
||
|
||
|
||
@app.get("/login", response_class=HTMLResponse)
|
||
def login_form():
|
||
return """<form method=post action=/login>
|
||
<input name=username placeholder=Benutzer>
|
||
<input name=password type=password placeholder=Passwort>
|
||
<button>Anmelden</button></form>"""
|
||
|
||
|
||
@app.post("/login")
|
||
def login(username: str = Form(...), password: str = Form(...)):
|
||
s = get_settings()
|
||
if not (hmac.compare_digest(username, s.gui_user)
|
||
and auth.verify_password(password, s.gui_password_hash)):
|
||
return HTMLResponse("Login fehlgeschlagen", status_code=401)
|
||
resp = RedirectResponse("/", status_code=303)
|
||
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
|
||
max_age=auth.MAX_AGE, samesite="lax")
|
||
return resp
|
||
|
||
|
||
@app.post("/logout")
|
||
def logout():
|
||
resp = RedirectResponse("/login", status_code=303)
|
||
resp.delete_cookie(auth.COOKIE)
|
||
return resp
|
||
|
||
|
||
@app.get("/api/accounts", dependencies=[Depends(auth.require_auth)])
|
||
def _placeholder_accounts(): # wird in Task 9 durch Router ersetzt
|
||
return []
|
||
```
|
||
|
||
- [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
|
||
|
||
**Files:**
|
||
- Create: `finance/app/routers/__init__.py`, `finance/app/routers/accounts.py`, `finance/app/routers/transactions.py`, `finance/app/routers/categories.py`, `finance/app/services/__init__.py`, `finance/app/services/categorize.py`, `finance/app/services/balances.py`, `finance/tests/test_crud_api.py`
|
||
- Modify: `finance/app/main.py` (Platzhalter-Route entfernen, Router einbinden)
|
||
|
||
**Interfaces:**
|
||
- Produces (alle unter `Depends(require_auth)`):
|
||
- `GET/POST /api/accounts`, `GET /api/accounts/{id}` — Felder wie ORM; Antwort enthält zusätzlich `balance` (aus `services.balances.account_balance`).
|
||
- `GET /api/transactions` (Filter: `account_id`, `date_from`, `date_to`, `category_id`, `q`, `status` Default `confirmed`, `limit` Default 200, `offset`), `POST /api/transactions` (manuelle Buchung; Status `confirmed`, `dedup_hash` via `validate.dedup_hash`; bei vorhandenem Hash 409 außer `force=true`), `PATCH /api/transactions/{id}` (nur `category_id`).
|
||
- `GET/POST /api/categories`, `GET/POST /api/category-rules`, `DELETE /api/category-rules/{id}`.
|
||
- `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`.
|
||
|
||
- [x] **Step 1: Failing Tests** — `finance/tests/test_crud_api.py`:
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.models.tables import Account, Statement, Transaction
|
||
from app.services.balances import account_balance
|
||
|
||
H = {"Authorization": "Bearer test-key"}
|
||
|
||
|
||
def test_account_crud_and_balance(client, db):
|
||
r = client.post("/api/accounts", headers=H, json={
|
||
"bank": "DKB", "iban": "DE02120300000000202051",
|
||
"name": "Giro", "type": "giro"})
|
||
assert r.status_code == 201
|
||
acc_id = r.json()["id"]
|
||
db.add(Statement(filename="a.pdf", bank="dkb", account_id=acc_id,
|
||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
||
opening_balance=Decimal("0"), closing_balance=Decimal("100.00"),
|
||
status="confirmed"))
|
||
db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2),
|
||
amount=Decimal("-30.00"), purpose="Bar", status="confirmed",
|
||
dedup_hash="x1"))
|
||
db.commit()
|
||
acc = db.get(Account, acc_id)
|
||
assert account_balance(db, acc) == Decimal("70.00")
|
||
|
||
|
||
def test_manual_transaction_dedup(client):
|
||
acc = client.post("/api/accounts", headers=H, json={
|
||
"bank": "DKB", "iban": "DE99", "name": "G", "type": "giro"}).json()
|
||
payload = {"account_id": acc["id"], "booking_date": "2026-07-01",
|
||
"amount": "-10.00", "purpose": "Kaffee", "counterparty": ""}
|
||
assert client.post("/api/transactions", headers=H, json=payload).status_code == 201
|
||
assert client.post("/api/transactions", headers=H, json=payload).status_code == 409
|
||
payload["force"] = True
|
||
assert client.post("/api/transactions", headers=H, json=payload).status_code == 201
|
||
|
||
|
||
def test_rules_categorize(client):
|
||
cat = client.post("/api/categories", headers=H, json={"name": "Energie"}).json()
|
||
client.post("/api/category-rules", headers=H,
|
||
json={"pattern": "stadtwerke", "category_id": cat["id"]})
|
||
acc = client.post("/api/accounts", headers=H, json={
|
||
"bank": "DKB", "iban": "DE98", "name": "G2", "type": "giro"}).json()
|
||
client.post("/api/transactions", headers=H, json={
|
||
"account_id": acc["id"], "booking_date": "2026-07-03",
|
||
"amount": "-80.00", "purpose": "STADTWERKE Abschlag", "counterparty": ""})
|
||
txs = client.get("/api/transactions", headers=H,
|
||
params={"account_id": acc["id"]}).json()
|
||
assert txs[0]["category_id"] == cat["id"]
|
||
```
|
||
|
||
- [x] **Step 2: Ausführen — FAIL**
|
||
|
||
- [x] **Step 3: Implementieren** — Router mit Pydantic-Schemas (`BaseModel` je Endpoint, `model_config = ConfigDict(from_attributes=True)`), Services:
|
||
|
||
`finance/app/services/categorize.py`:
|
||
```python
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.tables import CategoryRule, Transaction
|
||
|
||
|
||
def apply_rules(session: Session, transactions: list[Transaction]) -> int:
|
||
rules = session.execute(
|
||
select(CategoryRule).order_by(CategoryRule.priority)).scalars().all()
|
||
hits = 0
|
||
for tx in transactions:
|
||
if tx.category_id is not None:
|
||
continue
|
||
haystack = f"{tx.purpose} {tx.counterparty}".lower()
|
||
for rule in rules:
|
||
if rule.pattern.lower() in haystack:
|
||
tx.category_id = rule.category_id
|
||
hits += 1
|
||
break
|
||
return hits
|
||
```
|
||
|
||
`finance/app/services/balances.py`:
|
||
```python
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.tables import Account, Statement, Transaction
|
||
|
||
|
||
def account_balance(session: Session, account: Account) -> Decimal:
|
||
stmt = session.execute(
|
||
select(Statement).where(Statement.account_id == account.id,
|
||
Statement.status == "confirmed")
|
||
.order_by(Statement.period_end.desc())).scalars().first()
|
||
q = select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||
Transaction.account_id == account.id, Transaction.status == "confirmed")
|
||
if stmt is not None:
|
||
q = q.where(Transaction.booking_date > stmt.period_end)
|
||
base = stmt.closing_balance
|
||
else:
|
||
base = Decimal("0")
|
||
return Decimal(base) + Decimal(session.execute(q).scalar_one())
|
||
|
||
|
||
def total_balance(session: Session) -> Decimal:
|
||
accounts = session.execute(select(Account)).scalars().all()
|
||
return sum((account_balance(session, a) for a in accounts), Decimal("0"))
|
||
```
|
||
|
||
Router-Muster (`finance/app/routers/accounts.py`, transactions/categories analog — vollständige CRUD-Logik gemäß Interface-Block, manuelle Buchung ruft `validate.dedup_hash` + `apply_rules`):
|
||
```python
|
||
from decimal import Decimal
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, ConfigDict
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.auth import require_auth
|
||
from app.db import get_session
|
||
from app.models.tables import Account
|
||
from app.services.balances import account_balance
|
||
|
||
router = APIRouter(prefix="/api/accounts", tags=["accounts"],
|
||
dependencies=[Depends(require_auth)])
|
||
|
||
|
||
class AccountIn(BaseModel):
|
||
bank: str
|
||
iban: str
|
||
name: str
|
||
type: str = "giro"
|
||
|
||
|
||
class AccountOut(AccountIn):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
balance: Decimal = Decimal("0")
|
||
|
||
|
||
@router.get("", response_model=list[AccountOut])
|
||
def list_accounts(session: Session = Depends(get_session)):
|
||
out = []
|
||
for acc in session.execute(select(Account)).scalars():
|
||
item = AccountOut.model_validate(acc)
|
||
item.balance = account_balance(session, acc)
|
||
out.append(item)
|
||
return out
|
||
|
||
|
||
@router.post("", response_model=AccountOut, status_code=201)
|
||
def create_account(data: AccountIn, session: Session = Depends(get_session)):
|
||
if session.execute(select(Account).where(Account.iban == data.iban)).scalar():
|
||
raise HTTPException(409, "IBAN existiert bereits")
|
||
acc = Account(**data.model_dump())
|
||
session.add(acc)
|
||
session.commit()
|
||
session.refresh(acc)
|
||
return AccountOut.model_validate(acc)
|
||
```
|
||
In `main.py`: Platzhalter-Route `_placeholder_accounts` löschen, stattdessen `app.include_router(accounts.router)` etc.
|
||
|
||
- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: CRUD-API Konten/Buchungen/Kategorien/Regeln`
|
||
|
||
### Task 10: Import-API (Upload → Vorschau → Bestätigen)
|
||
|
||
**Files:**
|
||
- Create: `finance/app/routers/imports.py`, `finance/app/services/importer.py`, `finance/tests/test_import_api.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `services.importer.process_pdf(session, path: Path) -> Statement` — Ablauf: `registry.parse_pdf`; Konto per IBAN suchen, sonst `Account(bank=<bank>, iban=<iban oder "UNBEKANNT-"+filename>, name=iban, type="giro")` anlegen; `Statement` anlegen; bei `ParserError` → `status="error"` + `error_message`; bei `balance_difference != 0` → `status="error"`, `error_message=f"Saldo-Differenz {diff} EUR"`; sonst Draft-Transaktionen anlegen (`status="draft"`, `dedup_hash`, `is_duplicate=True`, wenn Hash schon als confirmed existiert), `apply_rules` auf die Drafts, `status="draft"`. PDF wird nach `uploads_dir` verschoben (bei Fehler: bleibt in Inbox).
|
||
- `POST /api/imports/upload` (multipart `file`) → speichert nach `inbox_dir`, ruft `process_pdf`, gibt Statement zurück (201).
|
||
- `POST /api/imports/scan-inbox` → verarbeitet alle `*.pdf` in `inbox_dir`, Rückgabe Liste.
|
||
- `GET /api/imports` → Statements mit Status; `GET /api/imports/{id}/preview` → `{statement, transactions: [...], balance_ok: bool, duplicates: int}`.
|
||
- `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`.
|
||
|
||
- [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
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app.parsers.base import ParsedStatement, ParsedTransaction
|
||
|
||
H = {"Authorization": "Bearer test-key"}
|
||
|
||
FAKE = ParsedStatement(
|
||
bank="dkb", iban="DE02120300000000202051",
|
||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
||
opening_balance=Decimal("100.00"), closing_balance=Decimal("40.00"),
|
||
transactions=[ParsedTransaction(date(2026, 6, 3), date(2026, 6, 3),
|
||
Decimal("-60.00"), "Miete Juni", "Vermieter")])
|
||
|
||
|
||
@pytest.fixture
|
||
def fake_parse(monkeypatch):
|
||
monkeypatch.setattr("app.services.importer.parse_pdf", lambda p: FAKE)
|
||
|
||
|
||
def _upload(client, name="auszug.pdf"):
|
||
return client.post("/api/imports/upload", headers=H,
|
||
files={"file": (name, b"%PDF-fake", "application/pdf")})
|
||
|
||
|
||
def test_upload_preview_confirm(client, fake_parse, tmp_path, monkeypatch):
|
||
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
|
||
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
|
||
from app.config import get_settings
|
||
get_settings.cache_clear()
|
||
r = _upload(client)
|
||
assert r.status_code == 201
|
||
sid = r.json()["id"]
|
||
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
|
||
assert prev["balance_ok"] is True
|
||
assert len(prev["transactions"]) == 1
|
||
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
|
||
txs = client.get("/api/transactions", headers=H).json()
|
||
assert len(txs) == 1 and txs[0]["status"] == "confirmed"
|
||
# Zweiter Import desselben Auszugs: alles Duplikate
|
||
sid2 = _upload(client, "auszug2.pdf").json()["id"]
|
||
prev2 = client.get(f"/api/imports/{sid2}/preview", headers=H).json()
|
||
assert prev2["duplicates"] == 1
|
||
client.post(f"/api/imports/{sid2}/confirm", headers=H)
|
||
assert len(client.get("/api/transactions", headers=H).json()) == 1
|
||
```
|
||
|
||
- [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).
|
||
|
||
- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Import-API mit Vorschau, Saldo-Pruefung und Duplikat-Schutz`
|
||
|
||
### Task 11: Planungs- & Szenario-API
|
||
|
||
**Files:**
|
||
- Create: `finance/app/routers/planning.py`, `finance/app/routers/scenarios.py`, `finance/app/services/projection_service.py`, `finance/app/services/suggestions.py`, `finance/tests/test_planning_api.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- CRUD: `GET/POST /api/recurring`, `PATCH/DELETE /api/recurring/{id}`; analog `/api/planned`, `/api/loans`; `GET /api/loans/{id}/schedule` → Tilgungsplan (Installments als JSON).
|
||
- `GET /api/recurring/suggestions` → `services.suggestions.suggest_recurring(session) -> list[dict]`: bestätigte Buchungen gruppiert nach `(account_id, counterparty, amount)`; Gruppen mit ≥ 3 aufeinanderfolgenden Monaten ⇒ Vorschlag `{name, amount, rhythm: "monthly", due_day (Median der Tage), category_id}`; bereits als RecurringItem vorhandene (gleicher Name + Betrag) werden ausgelassen.
|
||
- 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).
|
||
|
||
- [x] **Step 1: Failing Test** — `finance/tests/test_planning_api.py`:
|
||
```python
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from app.models.tables import Account, Statement
|
||
|
||
H = {"Authorization": "Bearer test-key"}
|
||
|
||
|
||
def _seed_balance(db, amount="1000.00"):
|
||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
||
db.add(acc)
|
||
db.flush()
|
||
db.add(Statement(filename="s.pdf", bank="dkb", account_id=acc.id,
|
||
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
|
||
opening_balance=Decimal("0"), closing_balance=Decimal(amount),
|
||
status="confirmed"))
|
||
db.commit()
|
||
|
||
|
||
def test_scenario_projection(client, db):
|
||
_seed_balance(db)
|
||
client.post("/api/recurring", headers=H, json={
|
||
"name": "Miete", "amount": "-600.00", "rhythm": "monthly", "due_day": 1})
|
||
sc = client.post("/api/scenarios", headers=H, json={"name": "Basis"}).json()
|
||
r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
|
||
params={"horizon_days": 92, "start_date": "2026-07-15"})
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
# 3 Mietzahlungen (01.08., 01.09., 01.10.): 400 -> -200 -> -800
|
||
assert Decimal(body["low_point_balance"]) == Decimal("-800.00")
|
||
assert body["below_zero_date"] == "2026-09-01"
|
||
assert len(body["series"]) == 92
|
||
|
||
|
||
def test_loan_in_scenario_keeps_balance_positive(client, db):
|
||
_seed_balance(db)
|
||
client.post("/api/recurring", headers=H, json={
|
||
"name": "Miete", "amount": "-600.00", "rhythm": "monthly", "due_day": 1})
|
||
loan = client.post("/api/loans", headers=H, json={
|
||
"name": "K1", "principal": "5000.00", "annual_rate_pct": "6.0",
|
||
"term_months": 48, "payout_date": "2026-07-20",
|
||
"repayment_type": "annuity"}).json()
|
||
sc = client.post("/api/scenarios", headers=H, json={"name": "Kredit"}).json()
|
||
client.post(f"/api/scenarios/{sc['id']}/loans/{loan['id']}", headers=H)
|
||
body = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
|
||
params={"horizon_days": 92, "start_date": "2026-07-15"}).json()
|
||
assert Decimal(body["low_point_balance"]) > Decimal("0")
|
||
```
|
||
|
||
- [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
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import delete, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.config import get_settings
|
||
from app.engine.loans import loan_schedule
|
||
from app.engine.projection import project
|
||
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
|
||
build_cashflows)
|
||
from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
|
||
ProjectionResult, RecurringItem, Scenario,
|
||
ScenarioLoan, ScenarioModifier)
|
||
from app.services.balances import total_balance
|
||
|
||
|
||
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
|
||
start_date: date) -> ProjectionResult:
|
||
end = start_date + timedelta(days=horizon_days)
|
||
recurring = []
|
||
if scenario.include_recurring:
|
||
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
||
r.due_day, r.start_date, r.end_date, r.category_id)
|
||
for r in session.execute(select(RecurringItem)).scalars()]
|
||
planned = []
|
||
if scenario.include_planned:
|
||
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||
for p in session.execute(select(PlannedItem)).scalars()]
|
||
loans = session.execute(
|
||
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
||
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
|
||
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
|
||
l.term_months, l.payout_date, l.repayment_type)
|
||
for l in loans]
|
||
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
|
||
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind, Decimal(m.value))
|
||
for m in session.execute(select(ScenarioModifier).where(
|
||
ScenarioModifier.scenario_id == scenario.id)).scalars()]
|
||
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,
|
||
start_date, end)
|
||
proj = project(total_balance(session), start_date, flows, horizon_days,
|
||
threshold=get_settings().warn_threshold)
|
||
session.execute(delete(ProjectionPoint).where(
|
||
ProjectionPoint.scenario_id == scenario.id))
|
||
for d, bal in proj.series:
|
||
session.add(ProjectionPoint(scenario_id=scenario.id, day=d, balance=bal))
|
||
result = session.get(ProjectionResult, scenario.id) or ProjectionResult(
|
||
scenario_id=scenario.id, computed_at=datetime.now(timezone.utc),
|
||
low_point_date=proj.low_point[0], low_point_balance=proj.low_point[1])
|
||
result.computed_at = datetime.now(timezone.utc)
|
||
result.low_point_date, result.low_point_balance = proj.low_point
|
||
result.below_zero_date = proj.first_below_zero
|
||
result.below_threshold_date = proj.first_below_threshold
|
||
session.add(result)
|
||
session.commit()
|
||
return result
|
||
```
|
||
|
||
- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Planungs- und Szenario-API mit Projektion und Vorschlaegen`
|
||
|
||
---
|
||
|
||
## Phase 6: Web-GUI
|
||
|
||
### Task 12: Basis-Layout, Übersicht, Buchungen
|
||
|
||
**Files:**
|
||
- Create: `finance/app/routers/gui.py`, `finance/app/templates/base.html`, `finance/app/templates/index.html`, `finance/app/templates/transactions.html`, `finance/app/static/htmx.min.js`, `finance/app/static/style.css`, `finance/tests/test_gui.py`
|
||
- Modify: `finance/app/main.py` (StaticFiles mounten, gui-Router einbinden, Jinja2Templates)
|
||
|
||
**Interfaces:**
|
||
- 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).
|
||
|
||
- [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
|
||
```
|
||
|
||
- [x] **Step 2: Failing Test** — `finance/tests/test_gui.py`:
|
||
```python
|
||
def test_pages_require_login(client):
|
||
for path in ("/", "/buchungen", "/import", "/planung"):
|
||
r = client.get(path, follow_redirects=False)
|
||
assert r.status_code in (302, 303), path
|
||
assert r.headers["location"] == "/login"
|
||
|
||
|
||
def test_pages_render_after_login(client):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
for path in ("/", "/buchungen"):
|
||
r = client.get(path)
|
||
assert r.status_code == 200
|
||
assert "Finanzberatung" in r.text
|
||
```
|
||
(`/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.)
|
||
|
||
- [x] **Step 3: Implementieren** — `base.html` mit Navigation (Übersicht / Import / Buchungen / Planung / Grafana-Link auf `http://<host>:8097`, Logout-Button), `<script src="/static/htmx.min.js"></script>`, `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, `<iframe>` auf Grafana-Dashboard. `transactions.html`: Filterformular (GET-Parameter an eigene Route, serverseitig gefiltert über dieselbe Query-Logik wie `/api/transactions`), Tabelle, Inline-`<select>` je Zeile für Kategorie (HTMX `hx-patch` auf `/api/transactions/{id}`), Formular „Buchung manuell erfassen" (HTMX-Post auf `/api/transactions`), Button „Regel daraus erzeugen" (Formular vorbefüllt mit counterparty als Pattern, Post auf `/api/category-rules`). GUI-Router-Muster:
|
||
```python
|
||
from fastapi import APIRouter, Depends, Request
|
||
from fastapi.responses import RedirectResponse
|
||
from fastapi.templating import Jinja2Templates
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.auth import COOKIE, session_valid
|
||
from app.db import get_session
|
||
|
||
templates = Jinja2Templates(directory="app/templates")
|
||
router = APIRouter()
|
||
|
||
|
||
def gui_session(request: Request):
|
||
if not session_valid(request.cookies.get(COOKIE)):
|
||
raise _redirect()
|
||
|
||
|
||
def _redirect():
|
||
from fastapi import HTTPException
|
||
exc = HTTPException(302)
|
||
exc.headers = {"Location": "/login"}
|
||
return exc
|
||
|
||
|
||
@router.get("/", dependencies=[Depends(gui_session)])
|
||
def index(request: Request, session: Session = Depends(get_session)):
|
||
... # Salden + anstehende Posten laden, templates.TemplateResponse("index.html", {...})
|
||
```
|
||
|
||
- [x] **Step 4: Ausführen — PASS** · **Step 5: Commit** — `feat: Web-GUI Basis, Uebersicht, Buchungsliste`
|
||
|
||
### Task 13: Import-Seite (Drag-and-Drop) und Planungs-Seite
|
||
|
||
**Files:**
|
||
- Create: `finance/app/templates/import.html`, `finance/app/templates/planning.html`, `finance/app/templates/_preview_table.html`
|
||
- Modify: `finance/app/routers/gui.py` (Routen `GET /import`, `GET /planung`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Import-API (Task 10), Planungs-API (Task 11).
|
||
|
||
- [x] **Step 1: Implementieren**
|
||
- `import.html`: Drop-Zone (`<div id="dropzone">`) mit kleinem Inline-Script: `dragover`/`drop`-Events, `FormData` + `fetch("/api/imports/upload", {method: "POST", body: fd})` (Cookie-Auth), danach `htmx.trigger("#imports", "refresh")`. Importliste als HTMX-Fragment (`hx-get`-Polling alle 5 s), Status-Badges (draft = „wartet auf Bestätigung", error = rot mit `error_message`, confirmed = grün). Je Draft: aufklappbare Vorschau (`_preview_table.html`: Buchungstabelle, Saldo-Status, Duplikat-Markierung) mit Buttons „Übernehmen" (`hx-post /api/imports/{id}/confirm`) und „Verwerfen" (`hx-delete`).
|
||
- `planning.html`: vier Abschnitte (wiederkehrende Posten inkl. „Vorschläge übernehmen"-Liste aus `/api/recurring/suggestions`, Einmalposten, Kredite mit Tilgungsplan-Aufklapper, Szenarien). Szenario-Abschnitt: Liste + Formular (Name, Checkboxen include_recurring/include_planned, Kredite zuordnen, Modifikatoren hinzufügen: Ziel-Dropdown Kategorie/Posten, Art percent/absolute/remove, Wert), Button „Durchrechnen" (`hx-post /api/scenarios/{id}/project`), Ergebnisanzeige (Tiefpunkt mit Datum, Unterschreitungs-Daten, Hinweis „Kurven in Grafana ansehen").
|
||
|
||
- [x] **Step 2: Alle Tests ausführen — PASS** (Test aus Task 12 deckt die neuen Routen ab)
|
||
|
||
- [x] **Step 3: Manuelle Prüfung** — App lokal starten und durchklicken:
|
||
```bash
|
||
cd /home/wlfb/bin/finance
|
||
FB_API_KEY=dev FB_GUI_PASSWORD_HASH=$(.venv/bin/python -c \
|
||
"from app.auth import hash_password; print(hash_password('dev'))") \
|
||
.venv/bin/uvicorn app.main:app --port 8000
|
||
```
|
||
Login mit admin/dev, alle vier Seiten öffnen, eine Test-PDF in die Drop-Zone ziehen (Fehlerfall „Bank nicht erkannt" ist ok und muss sauber angezeigt werden).
|
||
|
||
- [x] **Step 4: Commit** — `feat: Import-Seite mit Drag-and-Drop und Planungs-Seite`
|
||
|
||
---
|
||
|
||
## Phase 7: Grafana, Views, Container, Pod-Skript
|
||
|
||
### Task 14: SQL-Views + Grafana-Provisionierung
|
||
|
||
**Files:**
|
||
- Create: `finance/app/models/views.py`, `finance/grafana/provisioning/datasources/postgres.yaml`, `finance/grafana/provisioning/dashboards/provider.yaml`, `finance/grafana/dashboards/finanzen.json`
|
||
- Modify: `finance/app/main.py` (Views bei Startup anlegen, nur Postgres)
|
||
|
||
**Interfaces:**
|
||
- Produces: Views `v_balance_history(day, account, balance)` (Fensterfunktion, kumulierte bestätigte Buchungen je Konto), `v_balance_total(day, balance)`, `v_monthly_by_category(month, category, total)`, `v_projection(scenario, day, balance)`.
|
||
|
||
- [x] **Step 1: Views implementieren** — `finance/app/models/views.py`:
|
||
```python
|
||
from sqlalchemy import text
|
||
from sqlalchemy.engine import Engine
|
||
|
||
VIEWS: dict[str, str] = {
|
||
"v_balance_history": """
|
||
SELECT t.booking_date AS day, a.name AS account,
|
||
SUM(SUM(t.amount)) OVER (PARTITION BY a.id
|
||
ORDER BY t.booking_date) AS balance
|
||
FROM transactions t JOIN accounts a ON a.id = t.account_id
|
||
WHERE t.status = 'confirmed'
|
||
GROUP BY a.id, a.name, t.booking_date""",
|
||
"v_balance_total": """
|
||
SELECT booking_date AS day,
|
||
SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
|
||
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
|
||
"v_monthly_by_category": """
|
||
SELECT date_trunc('month', t.booking_date) AS month,
|
||
COALESCE(c.name, 'unkategorisiert') AS category,
|
||
SUM(t.amount) AS total
|
||
FROM transactions t LEFT JOIN categories c ON c.id = t.category_id
|
||
WHERE t.status = 'confirmed' GROUP BY 1, 2""",
|
||
"v_projection": """
|
||
SELECT s.name AS scenario, p.day, p.balance
|
||
FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id""",
|
||
}
|
||
|
||
|
||
def create_views(engine: Engine) -> None:
|
||
if engine.dialect.name != "postgresql":
|
||
return
|
||
with engine.begin() as conn:
|
||
for name, body in VIEWS.items():
|
||
conn.execute(text(f"CREATE OR REPLACE VIEW {name} AS {body}"))
|
||
```
|
||
In `main.py` via Lifespan-Handler aufrufen (`create_views(get_engine())`).
|
||
|
||
- [x] **Step 2: Grafana-Provisionierung** — `datasources/postgres.yaml`:
|
||
```yaml
|
||
apiVersion: 1
|
||
datasources:
|
||
- name: FinanzDB
|
||
type: postgres
|
||
access: proxy
|
||
url: localhost:5432
|
||
user: finance_read
|
||
jsonData:
|
||
database: finance
|
||
sslmode: disable
|
||
secureJsonData:
|
||
password: ${FINANCE_READ_PASSWORD}
|
||
```
|
||
`dashboards/provider.yaml`:
|
||
```yaml
|
||
apiVersion: 1
|
||
providers:
|
||
- name: finanzen
|
||
type: file
|
||
options:
|
||
path: /var/lib/grafana/dashboards
|
||
```
|
||
`finance/grafana/dashboards/finanzen.json`: ein Dashboard „Finanzen" mit vier Panels (Datenquelle `FinanzDB`, `rawSql`-Queries):
|
||
1. *Kontostand-Verlauf* (timeseries): `SELECT day AS time, account AS metric, balance AS value FROM v_balance_history ORDER BY day`
|
||
2. *Gesamt-Saldo* (timeseries): `SELECT day AS time, balance AS value FROM v_balance_total ORDER BY day`
|
||
3. *Monat nach Kategorie* (barchart, gestapelt): `SELECT month AS time, category AS metric, total AS value FROM v_monthly_by_category ORDER BY month`
|
||
4. *Szenario-Vergleich* (timeseries, Threshold-Linien bei 0 und Warnschwelle): `SELECT day AS time, scenario AS metric, balance AS value FROM v_projection ORDER BY day`
|
||
JSON per Hand aus einem minimalen Grafana-Dashboard-Export aufbauen (`schemaVersion` aktuell, `uid: "finanzen"`); endgültige Feinheiten (Stacking, Thresholds) beim Smoke-Test in Task 16 in der Grafana-UI justieren und JSON re-exportieren.
|
||
|
||
- [x] **Step 3: Tests laufen lassen — PASS** (Views werden auf SQLite übersprungen) · **Step 4: Commit** — `feat: SQL-Views und Grafana-Provisionierung`
|
||
|
||
### Task 15: Containerfile
|
||
|
||
**Files:**
|
||
- Create: `finance/Containerfile`, `finance/entrypoint.sh`
|
||
|
||
- [x] **Step 1: Schreiben**
|
||
|
||
`finance/Containerfile`:
|
||
```dockerfile
|
||
FROM docker.io/library/python:3.12.11-slim
|
||
|
||
WORKDIR /srv/finance
|
||
COPY requirements.txt .
|
||
RUN pip install --no-cache-dir -r requirements.txt
|
||
COPY app/ app/
|
||
COPY alembic/ alembic/
|
||
COPY alembic.ini .
|
||
COPY entrypoint.sh /entrypoint.sh
|
||
RUN chmod +x /entrypoint.sh
|
||
EXPOSE 8000
|
||
ENTRYPOINT ["/entrypoint.sh"]
|
||
```
|
||
(Falls `python:3.12.11-slim` nicht existiert: aktuelle 3.12-Patch-Version nachschlagen und pinnen.)
|
||
|
||
`finance/entrypoint.sh`:
|
||
```bash
|
||
#!/bin/sh
|
||
set -e
|
||
alembic upgrade head
|
||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||
```
|
||
|
||
- [x] **Step 2: Build testen**
|
||
```bash
|
||
cd /home/wlfb/bin/finance && podman build -t localhost/finance-api:0.1.0 .
|
||
```
|
||
Erwartet: Build erfolgreich.
|
||
|
||
- [x] **Step 3: Commit** — `feat: Containerfile und Entrypoint`
|
||
|
||
### Task 16: Pod-Skript `create_pod_finance.sh`
|
||
|
||
**Files:**
|
||
- Create: `create_pod_finance.sh` (im Repo-Root `/home/wlfb/bin`, ausführbar)
|
||
|
||
**Struktur — exakt dem Muster von `example_create_pod_langflow.sh` folgen** (Reihenfolge: Variablen → systemd-Stop → Verzeichnisse → Pod → Container → `podman generate systemd --name --new --files` → Pod stoppen/entfernen → `systemctl --user enable --now` → Readiness-Check):
|
||
|
||
- [x] **Step 1: Skript schreiben** — Eckdaten:
|
||
```bash
|
||
POD_NAME='finance_pod'
|
||
DB_CTR_NAME='finance-db_ctr'
|
||
API_CTR_NAME='finance-api_ctr'
|
||
GRAFANA_CTR_NAME='finance-grafana_ctr'
|
||
|
||
POSTGRES_IMAGE='docker.io/library/postgres:17.10'
|
||
GRAFANA_IMAGE='docker.io/grafana/grafana-oss:12.1.0' # vor Nutzung Tag verifizieren: podman pull
|
||
API_IMAGE='localhost/finance-api:0.1.0'
|
||
|
||
HOST_LOCAL_IP='127.0.0.1'
|
||
API_HOST_PORT='8096'; API_CONTAINER_PORT='8000'
|
||
GRAFANA_HOST_PORT='8097'; GRAFANA_CONTAINER_PORT='3000'
|
||
|
||
BIND_DIR="$HOME/.local/share/$POD_NAME"
|
||
# Unterverzeichnisse: postgres-data, grafana-data, inbox, uploads
|
||
ENV_FILE="$HOME/bin/finance/.env"
|
||
```
|
||
Besonderheiten gegenüber dem Beispiel:
|
||
1. **`.env`-Erzeugung beim ersten Lauf** (vor den Containern):
|
||
```bash
|
||
if [ ! -f "$ENV_FILE" ]; then
|
||
GUI_PASSWORD=$(openssl rand -base64 12)
|
||
cat > "$ENV_FILE" <<EOF
|
||
POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
||
FINANCE_READ_PASSWORD=$(openssl rand -hex 16)
|
||
FB_API_KEY=$(openssl rand -hex 32)
|
||
FB_SESSION_SECRET=$(openssl rand -hex 32)
|
||
FB_GUI_USER=admin
|
||
FB_GUI_PASSWORD_HASH=$(python3 -c "import hashlib,secrets;s=secrets.token_hex(16);print(s+'\$'+hashlib.pbkdf2_hmac('sha256',b'$GUI_PASSWORD',s.encode(),200000).hex())")
|
||
GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 12)
|
||
EOF
|
||
chmod 600 "$ENV_FILE"
|
||
echo "NEU ERZEUGT: GUI-Login admin / $GUI_PASSWORD (jetzt notieren!)"
|
||
echo "Grafana-Login admin / siehe GRAFANA_ADMIN_PASSWORD in $ENV_FILE"
|
||
fi
|
||
set -a; . "$ENV_FILE"; set +a
|
||
```
|
||
2. **Image-Build**: `podman build -t "$API_IMAGE" "$HOME/bin/finance"` vor Pod-Erstellung.
|
||
3. **Container-Starts**:
|
||
- Postgres: `-e POSTGRES_USER=finance -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" -e POSTGRES_DB=finance -v "$BIND_DIR/postgres-data:/var/lib/postgresql/data:Z"`; danach `pg_isready`-Warteschleife wie im Beispiel; dann Read-only-User idempotent anlegen:
|
||
```bash
|
||
podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
|
||
"DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='finance_read')
|
||
THEN CREATE ROLE finance_read LOGIN PASSWORD '$FINANCE_READ_PASSWORD'; END IF; END \$\$;
|
||
GRANT CONNECT ON DATABASE finance TO finance_read;
|
||
GRANT USAGE ON SCHEMA public TO finance_read;
|
||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO finance_read;
|
||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;"
|
||
```
|
||
- API: `-e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" -e FB_API_KEY -e FB_SESSION_SECRET -e FB_GUI_USER -e FB_GUI_PASSWORD_HASH -e FB_INBOX_DIR=/data/inbox -e FB_UPLOADS_DIR=/data/uploads -v "$BIND_DIR/inbox:/data/inbox:Z" -v "$BIND_DIR/uploads:/data/uploads:Z"`
|
||
- Grafana: `-e GF_SECURITY_ADMIN_PASSWORD="$GRAFANA_ADMIN_PASSWORD" -e FINANCE_READ_PASSWORD -v "$HOME/bin/finance/grafana/provisioning:/etc/grafana/provisioning:Z,ro" -v "$HOME/bin/finance/grafana/dashboards:/var/lib/grafana/dashboards:Z,ro" -v "$BIND_DIR/grafana-data:/var/lib/grafana:Z"`
|
||
4. **Readiness-Check** am Ende: curl auf `http://127.0.0.1:8096/login` und `http://127.0.0.1:8097/api/health`.
|
||
|
||
- [x] **Step 2: Ausführen und verifizieren**
|
||
```bash
|
||
chmod +x /home/wlfb/bin/create_pod_finance.sh && /home/wlfb/bin/create_pod_finance.sh
|
||
systemctl --user status pod-finance_pod.service --no-pager
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8096/login # 200
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8097/api/health # 200
|
||
```
|
||
|
||
- [x] **Step 3: End-to-End-Smoke-Test** — Im Browser: GUI-Login mit dem ausgegebenen Passwort, ein Fixture-PDF per Drag-and-Drop importieren, Vorschau bestätigen, Buchungen prüfen; Grafana öffnen, Dashboard „Finanzen" zeigt Daten; ein Basis-Szenario anlegen und durchrechnen. API mit Key testen:
|
||
```bash
|
||
. /home/wlfb/bin/finance/.env
|
||
curl -s -H "Authorization: Bearer $FB_API_KEY" http://127.0.0.1:8096/api/accounts
|
||
```
|
||
Grafana-Panel-Feinschliff (Task 14 Step 2) jetzt erledigen, JSON re-exportieren und einchecken.
|
||
|
||
- [x] **Step 4: Commit** — `feat: Pod-Skript finance_pod mit systemd-Service und Secrets-Bootstrap`
|
||
|
||
---
|
||
|
||
## Phase 8: Claude-Code-Umgebung in `/home/wlfb/fb`
|
||
|
||
### Task 17: CLAUDE.md und Skills
|
||
|
||
**Files (alle im Repo `/home/wlfb/fb` — dort committen!):**
|
||
- Create: `/home/wlfb/fb/CLAUDE.md`, `/home/wlfb/fb/.claude/skills/finanz-api/SKILL.md`, `/home/wlfb/fb/.claude/skills/finanzberatung/SKILL.md`, `/home/wlfb/fb/.claude/skills/auszug-import/SKILL.md`
|
||
|
||
- [x] **Step 1: CLAUDE.md schreiben** — Inhalt:
|
||
```markdown
|
||
# Finanzberatungs-Umgebung
|
||
|
||
Beratung und Auswertung laufen auf Deutsch.
|
||
|
||
## Finanzberatungs-Tool
|
||
- API + Web-GUI: http://127.0.0.1:8096 (OpenAPI: /docs), Grafana: http://127.0.0.1:8097
|
||
- API-Key: `FB_API_KEY` in `/home/wlfb/bin/finance/.env` (Header `Authorization: Bearer <key>`)
|
||
- Quellcode/Betrieb: Repo `/home/wlfb/bin` (Pod `finance_pod`,
|
||
`systemctl --user status pod-finance_pod.service`)
|
||
- Import-Inbox (PDFs hier ablegen): `~/.local/share/finance_pod/inbox/`
|
||
|
||
## Grundregeln
|
||
- Echte (bestätigte) Buchungen niemals ändern oder löschen — Korrekturen nur
|
||
als Kategorie-Anpassung; Zukunftsplanung ausschließlich über Szenarien.
|
||
- Importe immer über die Vorschau bestätigen lassen, nie blind übernehmen.
|
||
- Empfehlungen (Kredit, Kürzungen) stets mit durchgerechneten Szenarien und
|
||
Zahlen (Tiefpunkt, Unterschreitungs-Datum, Ratenhöhe) begründen.
|
||
- Für API-Nutzung den Skill `finanz-api` verwenden.
|
||
```
|
||
|
||
- [x] **Step 2: Skills schreiben** — jeweils `SKILL.md` mit Frontmatter (`name`, `description` mit „Use when …"-Trigger):
|
||
- `finanz-api`: Beschreibung aller Endpunkt-Gruppen (accounts, transactions, categories, category-rules, imports, recurring, planned, loans, scenarios, project) mit je einem konkreten curl-Beispiel, Muster:
|
||
```bash
|
||
KEY=$(grep '^FB_API_KEY=' /home/wlfb/bin/finance/.env | cut -d= -f2)
|
||
curl -s -H "Authorization: Bearer $KEY" http://127.0.0.1:8096/api/accounts | jq
|
||
```
|
||
- `finanzberatung`: Workflow — (1) Lage erfassen: Konten/Salden, unkategorisierte Buchungen, gepflegte wiederkehrende Posten prüfen (ggf. `/api/recurring/suggestions` durchgehen), (2) Basis-Szenario anlegen/durchrechnen, (3) Fragestellung in Varianten übersetzen (Kredit-Parameter, Kürzungs-Modifikatoren je Kategorie), (4) Varianten durchrechnen, (5) Vergleichstabelle (Tiefpunkt, Unterschreitungsdaten, Gesamtkosten Kredit aus Tilgungsplan) und Empfehlung formulieren; Hinweis auf Grafana-Dashboard „Szenario-Vergleich" für den Nutzer.
|
||
- `auszug-import`: Workflow — `GET /api/imports` auf `error`/`draft` prüfen; bei Drafts Vorschau dem Nutzer zusammenfassen und nach Bestätigung `confirm` aufrufen; bei Parser-Fehlern: PDF aus Inbox mit `finance/scripts/dump_pdf_text.py` ansehen, Regex-Konstanten im passenden Parser (`/home/wlfb/bin/finance/app/parsers/`) fixen, Tests laufen lassen, Image neu bauen + `create_pod_finance.sh` erneut ausführen.
|
||
|
||
- [x] **Step 3: Commit im fb-Repo**
|
||
```bash
|
||
cd /home/wlfb/fb && git add CLAUDE.md .claude/skills && \
|
||
git commit -m "feat: Beratungsumgebung (CLAUDE.md, Skills finanz-api/finanzberatung/auszug-import)"
|
||
```
|
||
|
||
---
|
||
|
||
## Abschluss-Checkliste
|
||
|
||
- [x] Alle Tests grün: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` (Fixture-Tests ggf. SKIP, sobald PDFs da sind: PASS Pflicht)
|
||
- [x] `pod-finance_pod.service` aktiv und `enabled`; GUI, API (mit Key) und Grafana erreichbar (Smoke-Test aus Task 16 Step 3 komplett durchlaufen)
|
||
- [x] Kein Secret, keine PDF, keine echten Kontodaten in `git log -p` beider Repos
|
||
- [x] Beide Repos committet (`~/bin`: Tool + Plan-Häkchen, `~/fb`: Umgebung)
|