Compare commits

..
9 Commits
Author SHA256 Message Date
rdmsandClaude Opus 5.5 b904709d0b docs: A12 Plan-Haken gesetzt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:52:17 +02:00
rdmsandClaude Opus 5.5 f79e1ce26b docs: ARCHITEKTUR – Kategorie System-Kreditraten (A12 Final-Review)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:46:24 +02:00
rdmsandClaude Opus 5.5 b78990786f chore: Release v0.12.0 (System-Präfix, Doppelzählungs-Warnung)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:40:03 +02:00
rdmsandClaude Opus 5.5 e665180b1f feat: Warnfenster und Planungs-Hinweis bei möglicher Doppelzählung (A12-T4)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:35:16 +02:00
rdmsandClaude Opus 5.5 9e856ec655 feat: API meldet mögliche Doppelzählung mit 409, Speichern nur mit Bestätigung (A12-T3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:30:58 +02:00
rdmsandClaude Opus 5.5 42d3d95e48 feat: Erkennung möglicher Doppelzählung Kredit/Fixposten (A12-T2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:27:30 +02:00
rdmsandClaude Opus 5.5 0a5fa90ae8 feat: Präfix System- für Tool-Serien reserviert, Kreditraten-Serie heißt System-Kreditraten (A12-T1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:24:41 +02:00
rdmsandClaude Opus 5.5 5a8e05596f docs: Plan Ausbaustufe 12 (6 Tasks)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:58:54 +02:00
rdmsandClaude Opus 5.5 404c1a5648 docs: Spec Ausbaustufe 12 (System-Präfix, Doppelzählungs-Warnung Kredit/Fixposten)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:51:19 +02:00
23 changed files with 2115 additions and 30 deletions
+36 -2
View File
@@ -40,8 +40,11 @@ flowchart TD
S_projection["projection_service.py<br/>run_projection, refresh_stale_projections"] S_projection["projection_service.py<br/>run_projection, refresh_stale_projections"]
S_planning_chart["planning_chart.py<br/>refresh_planning_chart"] S_planning_chart["planning_chart.py<br/>refresh_planning_chart"]
S_admin["admin.py<br/>change_password, apply_rules_retroactively"] S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
S_doublecount["double_count.py<br/>all_conflicts, conflicts_for_recurring,<br/>conflicts_for_loan"]
end end
N_reserved["app/reserved.py<br/>check_reserved_name"]
subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"] subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"]
E_loans["loans.py<br/>loan_schedule, annuity_payment, add_months"] E_loans["loans.py<br/>loan_schedule, annuity_payment, add_months"]
E_recurrence["recurrence.py<br/>occurrences"] E_recurrence["recurrence.py<br/>occurrences"]
@@ -86,7 +89,11 @@ flowchart TD
R_imports --> S_importer R_imports --> S_importer
R_planning --> E_loans R_planning --> E_loans
R_planning --> S_suggestions R_planning --> S_suggestions
R_planning --> S_doublecount
R_scenarios --> S_projection R_scenarios --> S_projection
R_scenarios --> N_reserved
R_categories --> N_reserved
R_gui --> S_doublecount
R_admin --> S_admin R_admin --> S_admin
ROUTERS -.-> M_tables ROUTERS -.-> M_tables
@@ -315,9 +322,36 @@ heute), je bereits durchgerechnetem Szenario einen Startpunkt exakt heute
Projektionspunkte NACH heute (`projection_points`, `day > CURRENT_DATE`) — Projektionspunkte NACH heute (`projection_points`, `day > CURRENT_DATE`) —
Punkte <= heute aus einer älteren Berechnung erscheinen so nie. Punkte <= heute aus einer älteren Berechnung erscheinen so nie.
`RESERVED_SCENARIO_NAME` ist für Szenario-Namen gesperrt: `RESERVED_SCENARIO_NAME` ist für Szenario-Namen gesperrt:
`routers/scenarios.py::_check_reserved_name` lehnt `POST`/`PATCH` mit diesem `app/reserved.py::check_reserved_name` lehnt `POST`/`PATCH` mit diesem
Namen (case-insensitiv) mit 422 ab. Namen (case-insensitiv) mit 422 ab.
**Präfix `System-` reserviert (Ausbaustufe 12).** `app/reserved.py`
(`SYSTEM_PREFIX = "System-"`, `is_reserved_name`, `check_reserved_name`)
sperrt diesen Präfix für Kategorie- und Szenario-Namen (`routers/categories.py`,
`routers/scenarios.py`, je `check_reserved_name` auf `POST`/`PATCH`, 422 bei
Verstoß) — er gehört den vom Tool erzeugten Serien `RESERVED_SCENARIO_NAME`
(`"System-Ist"`, `models/views.py`) und `services/planning_chart.py::KREDITRATEN`
(`"System-Kreditraten"`).
**Doppelzählungs-Erkennung Kredit ↔ Fixposten (Ausbaustufe 12).**
`app/services/double_count.py` erkennt, wenn ein monatlicher Ausgabe-Posten
(`RecurringItem`, `rhythm == "monthly"`) betraglich vermutlich einer regulären
Kreditrate (`regular_rate`, Annuität bzw. bei endfälligen Krediten die
monatlichen Zinsen) entspricht — Toleranz `max(TOLERANZ_MIN=1.00,
rate * TOLERANZ_PROZENT=3 / 100)` — und sich die Zeiträume überschneiden
(`rate_period`). `routers/planning.py` ruft `conflicts_for_recurring` bzw.
`conflicts_for_loan` bei `POST`/`PATCH` auf `/api/recurring` und
`/api/loans` auf; ohne `doppelzaehlung_bestaetigt: true` im Request-Body
antwortet es mit `409` (`detail.code == "doppelzaehlung_kredit"`, Konstante
`CODE`). `static/doppelzaehlung.js` fängt genau diesen 409 im globalen
`htmx:responseError`-Handler ab und zeigt das `<dialog id=
"doppelzaehlung-dialog">` aus `base.html`; „Trotzdem speichern“ markiert das
Formular (`data-doppelzaehlung-bestaetigt="1"`) und sendet es erneut, die
`json-form`-Erweiterung ergänzt dabei `doppelzaehlung_bestaetigt: true`.
`routers/gui.py::planung_page` ruft zusätzlich `all_conflicts` auf und zeigt
einen dauerhaften ⚠-Hinweis je betroffenem Posten/Kredit in `planning.html`,
solange der Konflikt fortbesteht.
**Abgeleitete Tabellen + Advisory-Locks (Ausbaustufe 11).** Zwei Datenstände **Abgeleitete Tabellen + Advisory-Locks (Ausbaustufe 11).** Zwei Datenstände
werden bei GUI-Seitenaufrufen automatisch aufgefrischt, nie beim reinen werden bei GUI-Seitenaufrufen automatisch aufgefrischt, nie beim reinen
API-Zugriff (`routers/gui.py::_safe_refresh`, fehlerresistent — ein Fehler API-Zugriff (`routers/gui.py::_safe_refresh`, fehlerresistent — ein Fehler
@@ -329,7 +363,7 @@ neu (Advisory-Lock `STALE_PROJECTION_LOCK = 110_001`), aufgerufen von
schreibt die Tabelle `planning_monthly` (`models/tables.py::PlanningMonthly`, schreibt die Tabelle `planning_monthly` (`models/tables.py::PlanningMonthly`,
Migration `d5e8b3c7a410`) komplett neu — Monatssummen je Kategorie aus Migration `d5e8b3c7a410`) komplett neu — Monatssummen je Kategorie aus
Planung (wiederkehrende Posten, Einmalposten, Kreditraten aller Kredite Planung (wiederkehrende Posten, Einmalposten, Kreditraten aller Kredite
unter Kategorie „Kreditraten") für das Grafana-Panel „Planung nach unter Kategorie „System-Kreditraten") für das Grafana-Panel „Planung nach
Kategorie" (`Advisory-Lock PLANNING_CHART_LOCK = 110_002`), aufgerufen von Kategorie" (`Advisory-Lock PLANNING_CHART_LOCK = 110_002`), aufgerufen von
`index` und `planung_page`. Beide Tabellen sind jederzeit reproduzierbar `index` und `planung_page`. Beide Tabellen sind jederzeit reproduzierbar
(kein Backup-Sonderfall). (kein Backup-Sonderfall).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,249 @@
# Design — Ausbaustufe 12: System-Präfix + Doppelzählungs-Warnung Kredit/Fixposten (v0.12.0)
> Status: Design im Chat vom Nutzer freigegeben (2026-09-28), Spec zur
> Durchsicht. Anlass: (1) Eine echte Kategorie „Kreditraten" würde im
> Grafana-Panel „Planung nach Kategorie" mit der synthetischen
> Kreditraten-Serie verschmelzen. (2) Wird eine Kreditrate sowohl als
> wiederkehrender Posten als auch als Kredit gepflegt, zählt sie in Planung
> und Projektion doppelt — das passiert leicht versehentlich, ein Hilfetext
> allein schützt nicht.
## Nutzerentscheidungen (2026-09-28)
- **Reservierter Präfix „System-"** für alle vom Tool erzeugten Serien.
Kategorie- und Szenarionamen mit diesem Präfix werden abgelehnt.
Kreditraten-Serie heißt künftig **„System-Kreditraten"**; „System-Ist"
fällt unter dieselbe Regel.
- **„unkategorisiert" bleibt** unverändert (Verschmelzen mit einer echten
gleichnamigen Kategorie ist semantisch harmlos; Beschriftung bleibt
gleich zum Ist-Diagramm „Monat nach Kategorie").
- **Aktiver Doppelzählungs-Check** statt Hilfevermerk: Speichern wird bei
Verdacht blockiert und ist nur nach **expliziter Bestätigung** in einem
Warnfenster möglich.
- **Toleranz** Betragsvergleich: **±3 %, mindestens 1,00 €**.
- **Dauerhafter Hinweis** auf der Planung-Seite an betroffenen Posten und
Krediten (nicht blockierend).
- **Version v0.12.0** (neues API-Verhalten).
## Befund (Ist-Zustand, v0.11.0)
- `RESERVED_SCENARIO_NAME = "System-Ist"` in `models/views.py`;
`routers/scenarios.py::_check_reserved_name` prüft nur diesen einen Namen.
- `services/planning_chart.py`: `KREDITRATEN = "Kreditraten"`.
- `routers/categories.py`: `create_category`/`patch_category` ohne
Namensreservierung.
- `routers/planning.py`: `create_recurring`/`patch_recurring`,
`create_loan`/`patch_loan` ohne Querprüfung. Schemas: `RecurringIn`
(Basis von `RecurringOut`), `RecurringPatch`, `LoanIn` (Basis von
`LoanOut`), `LoanPatch`.
- GUI-Formulare gegen diese Endpunkte (alle `hx-ext="json-form"`,
`planning.html`): Fixposten anlegen (`hx-post /api/recurring`),
bearbeiten (`hx-patch /api/recurring/{id}`), „Vorschlag übernehmen"
(`hx-post /api/recurring`), Kredit anlegen (`hx-post /api/loans`),
bearbeiten (`hx-patch /api/loans/{id}`).
- Fehleranzeige global in `base.html`: `htmx:responseError` →
`alert('Fehler: ' + responseText)`.
- Live-Bestand geprüft (2026-09-28): keine Kategorie/kein Szenario beginnt
mit „System-", keine Kategorie heißt „Kreditraten"/„unkategorisiert";
0 Kredite.
- Beratungsumgebung `/home/wlfb/fb`: Skill `finanz-api` dokumentiert
`POST /api/recurring` und `POST /api/loans`, Skill `finanzberatung` legt
Kredite per API an → vom neuen 409-Verhalten betroffen.
## 1. Reservierter Präfix „System-"
Neues Modul `app/reserved.py` (einzige Quelle):
```python
SYSTEM_PREFIX = "System-"
def is_reserved_name(name: str) -> bool:
"""True, wenn name (strip + casefold) mit SYSTEM_PREFIX beginnt."""
def check_reserved_name(name: str | None) -> None:
"""None -> nichts; reserviert -> HTTPException(422, <Meldung>)."""
```
Meldung (422): **„Namen, die mit »System-« beginnen, sind für vom Tool
erzeugte Einträge reserviert"**.
- `models/views.py`: `RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist"`
(Wert unverändert „System-Ist", View-SQL unverändert).
- `services/planning_chart.py`: `KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten"`.
- `routers/scenarios.py`: `_check_reserved_name` entfällt, stattdessen
`check_reserved_name` in `create_scenario` und `patch_scenario` (gleiche
Stellen wie bisher).
- `routers/categories.py`: `check_reserved_name(data.name)` als erste
Zeile in `create_category` und `patch_category`.
- Grafana-Panel 5 zeigt ab dem nächsten Seitenaufruf „System-Kreditraten"
(Tabelle `planning_monthly` wird ohnehin komplett neu geschrieben — keine
Migration nötig).
Tests: Kategorien und Szenarien je POST/PATCH mit „System-X",
„system-kreditraten", „ SYSTEM-Ist " → 422 mit Meldung; „Kreditraten",
„Systemkosten", „Ist-System" → erlaubt (201/200). Planning-Chart-Tests
nutzen die Konstante (Wert „System-Kreditraten").
## 2. Doppelzählungs-Erkennung (`app/services/double_count.py`)
Reine Logik ohne HTTP-Abhängigkeit.
```python
TOLERANZ_PROZENT = Decimal("3")
TOLERANZ_MIN = Decimal("1.00")
CODE = "doppelzaehlung_kredit"
@dataclass(frozen=True)
class Konflikt:
posten_id: int | None # None = Posten noch nicht gespeichert
posten_name: str
posten_betrag: Decimal # negativ
kredit_id: int | None # None = Kredit noch nicht gespeichert
kredit_name: str
kredit_rate: Decimal # positiv, reguläre Monatsrate
def regular_rate(principal, annual_rate_pct, term_months, repayment_type) -> Decimal | None
def rate_period(payout_date, term_months) -> tuple[date, date]
def is_conflict(posten, kredit) -> bool
def conflicts_for_recurring(session, posten, exclude_id=None) -> list[Konflikt]
def conflicts_for_loan(session, kredit, exclude_id=None) -> list[Konflikt]
def all_conflicts(session) -> list[Konflikt]
```
`posten`/`kredit` sind einfache Wertobjekte (Name, Betrag bzw. Kreditdaten,
Rhythmus, Start/Ende) — so lassen sich auch noch nicht gespeicherte bzw.
per PATCH veränderte Zustände prüfen.
**Reguläre Monatsrate** (`regular_rate`, positiv, auf Cent gerundet):
- `annuity`: `engine/loans.py::annuity_payment(principal, rate, term)`.
- `bullet`: monatliche Zinsen `principal × rate / 100 / 12` (ROUND_HALF_UP).
Bei Zinssatz 0 → `None` (keine reguläre Rate → nie Konflikt).
- Schlusszahlung endfälliger Kredite wird nicht betrachtet.
**Ratenzeitraum** (`rate_period`): erste Rate
`add_months(payout_date, 1)`, letzte Rate `add_months(payout_date,
term_months)` (identisch zu `loan_schedule`).
**Konflikt** (`is_conflict`), wenn ALLE gelten:
1. Posten-Rhythmus `monthly` und Posten-Betrag `< 0`;
2. Kredit hat eine reguläre Rate `r` (nicht `None`);
3. `abs(abs(betrag) − r) ≤ max(TOLERANZ_MIN, r × TOLERANZ_PROZENT / 100)`;
4. Zeiträume überschneiden sich: `(start is None or start ≤ letzte_rate)
and (ende is None or ende ≥ erste_rate)`.
Kein Namensvergleich (unzuverlässig). Bekannte Grenze: weicht der Posten
stärker als die Toleranz ab (z.B. Rate inkl. Versicherung), wird nichts
erkannt. Alle Beträge `Decimal`.
Tests (Handrechnung, künstliche Werte): Annuität exakt / knapp innerhalb /
knapp außerhalb der 3 %-Grenze; Mindesttoleranz 1,00 € bei kleiner Rate;
endfällig (Zinsrate) inkl. Zinssatz 0 → kein Konflikt; Rhythmus
quarterly → kein Konflikt; positiver Betrag → kein Konflikt; Zeiträume
disjunkt (Posten endet vor erster Rate / startet nach letzter Rate) →
kein Konflikt; offene Start/Ende → Konflikt; `exclude_id` schließt den
bearbeiteten Datensatz selbst aus; `all_conflicts` findet mehrere Paare.
## 3. API-Verhalten (`routers/planning.py`)
- Neues Anfragefeld **`doppelzaehlung_bestaetigt: bool = False`** — nur in
den Request-Modellen (`RecurringCreate(RecurringIn)`, `RecurringPatch`,
`LoanCreate(LoanIn)`, `LoanPatch`), **nicht** in `RecurringOut`/`LoanOut`,
nicht persistiert, beim Anlegen der ORM-Objekte aus `model_dump`
ausgeschlossen.
- **POST** `/api/recurring` bzw. `/api/loans`: vor dem Speichern
`conflicts_for_recurring`/`conflicts_for_loan` für den neuen Datensatz;
Treffer und Flag nicht gesetzt → **409** (nichts gespeichert).
- **PATCH** `/api/recurring/{id}` bzw. `/api/loans/{id}`: Konflikte vor und
nach Anwendung der Änderung ermitteln (bezogen auf diesen Datensatz);
409 nur, wenn nach der Änderung ein Konflikt-Paar existiert, das vorher
nicht existierte (ein bereits bestätigter Konflikt fragt bei reiner
Namensänderung nicht erneut). Bei 409 `session.rollback()`.
- **409-Antwort** (`HTTPException(409, detail={...})`):
```json
{"detail": {"code": "doppelzaehlung_kredit",
"meldung": "<deutscher Erklärtext>",
"konflikte": [{"posten": "...", "posten_betrag": "-312.50",
"kredit": "...", "kredit_rate": "312.50"}]}}
```
Beträge als Strings mit Punkt-Dezimal (API-Konvention).
- Flag `true` → Speichern trotz Konflikt (201/200 wie bisher).
- Andere Endpunkte unverändert (Einmalposten, Szenario-Zuordnung).
Tests: je Endpunkt (POST/PATCH × Posten/Kredit) Konflikt → 409 mit
`code`, `konflikte` und nichts gespeichert/geändert; mit Flag → 201/200 und
gespeichert; ohne Konflikt → unverändert 201/200; PATCH einer reinen
Namensänderung bei bestehendem Konflikt → 200 ohne Flag; `RecurringOut`/
`LoanOut` enthalten kein `doppelzaehlung_bestaetigt`; bestehende Tests
bleiben grün.
## 4. GUI: Warnfenster (`base.html`)
- Ein `<dialog id="doppelzaehlung-dialog">` im Basis-Layout mit Titel
„Mögliche Doppelzählung", Erklärtext (`meldung`), Liste der Konflikte
(„Posten „X" (−312,50 €) ↔ Kredit „Y" (Rate 312,50 €)", Beträge deutsch
formatiert) und Warnsatz („Beide zusammen zählen die Rate doppelt —
Planung und Szenarien wären zu pessimistisch."). Buttons:
**„Abbrechen"** (`autofocus`, schließt ohne Speichern) und
**„Trotzdem speichern"**.
- `htmx:responseError`-Handler: Status 409 und JSON-`detail.code ===
"doppelzaehlung_kredit"` → Dialog füllen + `showModal()`, **kein**
`alert`. Alle anderen Fehler wie bisher per `alert`.
- „Trotzdem speichern": setzt am auslösenden Formular
`data-doppelzaehlung-bestaetigt="1"` und löst es erneut aus
(`htmx.trigger(form, 'submit')`); die `json-form`-Extension ergänzt bei
gesetztem Attribut `doppelzaehlung_bestaetigt: true`; nach dem Request
(`htmx:afterRequest`) wird das Attribut wieder entfernt.
- Gilt automatisch für alle fünf Formulare (Anlegen/Bearbeiten Posten,
Vorschlag übernehmen, Anlegen/Bearbeiten Kredit).
- UX-Regel: Formular bleibt sichtbar; Abbrechen lässt Eingaben stehen.
## 5. Dauerhafter Hinweis (Planung-Seite)
- `routers/gui.py::planung_page` ermittelt `all_conflicts(session)` und
übergibt je Posten-ID bzw. Kredit-ID die Gegenseite(n).
- `planning.html`: in der Namens-Zelle betroffener Posten/Kredite ein
`<span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „Y"</span>`
bzw. „… mit Posten „X"". Die Namens-Zelle bekommt `data-sort="{{ name }}"`,
damit der Hinweis die Sortierung nicht beeinflusst.
- CSS `.warn-hint` dezent (kleine Schrift, Warnfarbe), nicht blockierend.
Tests: GUI `/planung` mit einem Konflikt-Paar zeigt den Hinweis an Posten
und Kredit; ohne Konflikt kein Hinweis; `data-sort` der Namenszellen
enthält nur den Namen.
## 6. Doku / Beratungsumgebung / Release
- `hilfe.html`: kurzer Absatz „Reserviert: Namen mit »System-«" und
„Doppelzählungs-Warnung Kredit/Fixposten" (was geprüft wird, Toleranz,
Bestätigung, Hinweis in der Planung).
- `docs/ARCHITEKTUR.md`: `reserved.py`, `double_count.py` (Diagrammnamen
müssen im Code existieren).
- **`/home/wlfb/fb/.claude/skills/finanz-api/SKILL.md`** (eigenes Repo,
eigener Commit): Abschnitte „Wiederkehrende Posten" und „Kredite" um die
409-Antwort `doppelzaehlung_kredit` und das Feld
`doppelzaehlung_bestaetigt` ergänzen, mit der Regel: **Flag nur nach
ausdrücklicher Rückfrage beim Nutzer setzen**; Reservierung „System-" für
Kategorien/Szenarien erwähnen.
- `finance/VERSION` 0.11.0 → **0.12.0**; Backup, Redeploy, Live-Smoke
(Wegwerf-Posten/-Kredit mit Konflikt → 409, mit Flag → 201, wieder
löschen; Kategorie „System-Test" → 422; Panel 5 zeigt
„System-Kreditraten" nur wenn Kredite existieren; Bestandszahlen
unverändert).
## Abgrenzung
- Keine Prüfung von Einmalposten (z.B. Schlusszahlung endfällig).
- Keine Prüfung bei Szenario-Kredit-Zuordnung.
- Kein Namensabgleich Posten↔Kredit.
- Keine Umbenennung von „unkategorisiert".
- Keine Migration (keine Schemaänderung).
## Rahmenregeln
- DATENSCHUTZ: Tests nur mit künstlichen Werten; keine Live-Beträge/-Namen
in Commits, Ledger, Chat.
- Decimal statt float für alle Beträge (auch Toleranzrechnung).
- Deutsch für GUI-Texte, Meldungen, Commits; Datum TT.MM.JJJJ.
- UX-Regel: Bedienelemente sichtbar; Warnung per Dialog mit expliziter
Bestätigung, sonst bestehender Fehler-Alert.
- Fable-Testagent-Gate je Plan-Task.
+1 -1
View File
@@ -1 +1 @@
0.11.0 0.12.0
+4 -3
View File
@@ -1,10 +1,11 @@
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from app.reserved import SYSTEM_PREFIX
# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection). # Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
# Für Szenarien reserviert (routers/scenarios.py lehnt ihn ab), damit kein # Für Szenarien reserviert über den System-Präfix, siehe app/reserved.py.
# Szenario mit der Ist-Serie verschmelzen kann (Ausbaustufe 11). RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist"
RESERVED_SCENARIO_NAME = "System-Ist"
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the # Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
# same math as app.services.balances.account_balance, expressed as a single # same math as app.services.balances.account_balance, expressed as a single
+24
View File
@@ -0,0 +1,24 @@
"""Reservierte Namen (Ausbaustufe 12).
Alles, was mit SYSTEM_PREFIX beginnt, gehört dem Tool: synthetische Serien
wie „System-Ist" (v_projection) oder „System-Kreditraten" (Planungs-Panel).
Kategorien und Szenarien dürfen solche Namen nicht tragen, sonst verschmelzen
sie in Grafana mit den Tool-Serien.
"""
from fastapi import HTTPException
SYSTEM_PREFIX = "System-"
RESERVED_MESSAGE = (f"Namen, die mit »{SYSTEM_PREFIX}« beginnen, sind für "
"vom Tool erzeugte Einträge reserviert")
def is_reserved_name(name: str) -> bool:
"""True, wenn name (ohne Randleerzeichen, Groß/klein egal) mit dem
System-Präfix beginnt."""
return name.strip().casefold().startswith(SYSTEM_PREFIX.casefold())
def check_reserved_name(name: str | None) -> None:
"""None (Feld nicht gesetzt) -> keine Prüfung; reserviert -> 422."""
if name is not None and is_reserved_name(name):
raise HTTPException(422, RESERVED_MESSAGE)
+3
View File
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from app.auth import require_auth from app.auth import require_auth
from app.db import get_session from app.db import get_session
from app.models.tables import Category, CategoryRule from app.models.tables import Category, CategoryRule
from app.reserved import check_reserved_name
router = APIRouter(prefix="/api", tags=["categories"], router = APIRouter(prefix="/api", tags=["categories"],
dependencies=[Depends(require_auth)]) dependencies=[Depends(require_auth)])
@@ -39,6 +40,7 @@ def list_categories(session: Session = Depends(get_session)):
@router.post("/categories", response_model=CategoryOut, status_code=201) @router.post("/categories", response_model=CategoryOut, status_code=201)
def create_category(data: CategoryIn, session: Session = Depends(get_session)): def create_category(data: CategoryIn, session: Session = Depends(get_session)):
check_reserved_name(data.name)
if session.execute(select(Category).where(Category.name == data.name)).scalar(): if session.execute(select(Category).where(Category.name == data.name)).scalar():
raise HTTPException(409, "Kategorie existiert bereits") raise HTTPException(409, "Kategorie existiert bereits")
cat = Category(**data.model_dump()) cat = Category(**data.model_dump())
@@ -51,6 +53,7 @@ def create_category(data: CategoryIn, session: Session = Depends(get_session)):
@router.patch("/categories/{category_id}", response_model=CategoryOut) @router.patch("/categories/{category_id}", response_model=CategoryOut)
def patch_category(category_id: int, data: CategoryIn, def patch_category(category_id: int, data: CategoryIn,
session: Session = Depends(get_session)): session: Session = Depends(get_session)):
check_reserved_name(data.name)
cat = session.get(Category, category_id) cat = session.get(Category, category_id)
if cat is None: if cat is None:
raise HTTPException(404, "Kategorie nicht gefunden") raise HTTPException(404, "Kategorie nicht gefunden")
+8
View File
@@ -27,6 +27,7 @@ from app.routers.planning import recurring_suggestions as _recurring_suggestions
from app.routers.scenarios import list_scenarios as _list_scenarios from app.routers.scenarios import list_scenarios as _list_scenarios
from app.routers.transactions import count_transactions, list_transactions, normalize_sort from app.routers.transactions import count_transactions, list_transactions, normalize_sort
from app.services.balances import account_balance, total_balance from app.services.balances import account_balance, total_balance
from app.services.double_count import all_conflicts
from app.services.planning_chart import refresh_planning_chart from app.services.planning_chart import refresh_planning_chart
from app.services.projection_service import refresh_stale_projections from app.services.projection_service import refresh_stale_projections
from app.version import get_version from app.version import get_version
@@ -337,6 +338,11 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
recurring = _list_recurring(session=session) recurring = _list_recurring(session=session)
planned = _list_planned(session=session) planned = _list_planned(session=session)
loans = _list_loans(session=session) loans = _list_loans(session=session)
doppelt_posten: dict[int, list[str]] = {}
doppelt_kredit: dict[int, list[str]] = {}
for k in all_conflicts(session):
doppelt_posten.setdefault(k.posten_id, []).append(k.kredit_name)
doppelt_kredit.setdefault(k.kredit_id, []).append(k.posten_name)
return templates.TemplateResponse(request, "planning.html", { return templates.TemplateResponse(request, "planning.html", {
"categories": categories, "categories": categories,
"category_names": {c.id: c.name for c in categories}, "category_names": {c.id: c.name for c in categories},
@@ -347,6 +353,8 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
"loans": loans, "loans": loans,
"rhythms": ["monthly", "quarterly", "yearly"], "rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"], "repayment_types": ["annuity", "bullet"],
"doppelt_posten": doppelt_posten,
"doppelt_kredit": doppelt_kredit,
}) })
+69 -6
View File
@@ -12,6 +12,10 @@ from app.db import get_session
from app.engine.loans import loan_schedule from app.engine.loans import loan_schedule
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem, from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
ScenarioLoan) ScenarioLoan)
from app.services.double_count import (CODE as DOPPELZAEHLUNG_CODE, Konflikt,
KreditDaten, PostenDaten,
conflicts_for_loan, conflicts_for_recurring,
kredit_aus, posten_aus)
from app.services.suggestions import suggest_recurring from app.services.suggestions import suggest_recurring
router = APIRouter(prefix="/api", tags=["planning"], router = APIRouter(prefix="/api", tags=["planning"],
@@ -36,6 +40,11 @@ class RecurringIn(BaseModel):
return self return self
class RecurringCreate(RecurringIn):
# Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert).
doppelzaehlung_bestaetigt: bool = False
class RecurringOut(RecurringIn): class RecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -49,6 +58,7 @@ class RecurringPatch(BaseModel):
start_date: date | None = None start_date: date | None = None
end_date: date | None = None end_date: date | None = None
category_id: int | None = None category_id: int | None = None
doppelzaehlung_bestaetigt: bool = False
class SuggestionOut(BaseModel): class SuggestionOut(BaseModel):
@@ -66,6 +76,26 @@ def _check_category(session: Session, category_id: int | None) -> None:
raise HTTPException(404, "Kategorie nicht gefunden") raise HTTPException(404, "Kategorie nicht gefunden")
_CENT = Decimal("0.01")
_FLAG = "doppelzaehlung_bestaetigt"
def _doppelzaehlung(konflikte: list[Konflikt]) -> HTTPException:
"""409 mit strukturierter Erklärung; die GUI zeigt daraus das Warnfenster."""
return HTTPException(409, detail={
"code": DOPPELZAEHLUNG_CODE,
"meldung": ("Der Betrag entspricht vermutlich einer Kreditrate, die bereits "
"über „Kredite“ eingeplant ist (oder umgekehrt). Beide zusammen "
"zählen die Rate doppelt – Planung und Szenarien wären zu "
"pessimistisch. Nur speichern, wenn das wirklich gewollt ist."),
"konflikte": [{"posten": k.posten_name,
"posten_betrag": str(k.posten_betrag.quantize(_CENT)),
"kredit": k.kredit_name,
"kredit_rate": str(k.kredit_rate.quantize(_CENT))}
for k in konflikte],
})
@router.get("/recurring", response_model=list[RecurringOut]) @router.get("/recurring", response_model=list[RecurringOut])
def list_recurring(session: Session = Depends(get_session)): def list_recurring(session: Session = Depends(get_session)):
return [RecurringOut.model_validate(r) return [RecurringOut.model_validate(r)
@@ -73,9 +103,16 @@ def list_recurring(session: Session = Depends(get_session)):
@router.post("/recurring", response_model=RecurringOut, status_code=201) @router.post("/recurring", response_model=RecurringOut, status_code=201)
def create_recurring(data: RecurringIn, session: Session = Depends(get_session)): def create_recurring(data: RecurringCreate, session: Session = Depends(get_session)):
_check_category(session, data.category_id) _check_category(session, data.category_id)
item = RecurringItem(**data.model_dump()) fields = data.model_dump(exclude={_FLAG})
if not data.doppelzaehlung_bestaetigt:
konflikte = conflicts_for_recurring(session, PostenDaten(
None, fields["name"], fields["amount"], fields["rhythm"],
fields["start_date"], fields["end_date"]))
if konflikte:
raise _doppelzaehlung(konflikte)
item = RecurringItem(**fields)
session.add(item) session.add(item)
session.commit() session.commit()
session.refresh(item) session.refresh(item)
@@ -94,7 +131,8 @@ def patch_recurring(item_id: int, data: RecurringPatch,
item = session.get(RecurringItem, item_id) item = session.get(RecurringItem, item_id)
if item is None: if item is None:
raise HTTPException(404, "Fixposten nicht gefunden") raise HTTPException(404, "Fixposten nicht gefunden")
fields = data.model_dump(exclude_unset=True) vorher = {k.paar for k in conflicts_for_recurring(session, posten_aus(item))}
fields = data.model_dump(exclude_unset=True, exclude={_FLAG})
if "category_id" in fields: if "category_id" in fields:
_check_category(session, fields["category_id"]) _check_category(session, fields["category_id"])
for key, value in fields.items(): for key, value in fields.items():
@@ -103,6 +141,12 @@ def patch_recurring(item_id: int, data: RecurringPatch,
and item.end_date < item.start_date): and item.end_date < item.start_date):
session.rollback() session.rollback()
raise HTTPException(422, "Ende darf nicht vor Start liegen") raise HTTPException(422, "Ende darf nicht vor Start liegen")
if not data.doppelzaehlung_bestaetigt:
neu = [k for k in conflicts_for_recurring(session, posten_aus(item))
if k.paar not in vorher]
if neu:
session.rollback()
raise _doppelzaehlung(neu)
session.commit() session.commit()
session.refresh(item) session.refresh(item)
return RecurringOut.model_validate(item) return RecurringOut.model_validate(item)
@@ -188,6 +232,10 @@ class LoanIn(BaseModel):
repayment_type: Literal["annuity", "bullet"] = "annuity" repayment_type: Literal["annuity", "bullet"] = "annuity"
class LoanCreate(LoanIn):
doppelzaehlung_bestaetigt: bool = False
class LoanOut(LoanIn): class LoanOut(LoanIn):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -200,6 +248,7 @@ class LoanPatch(BaseModel):
term_months: int | None = None term_months: int | None = None
payout_date: date | None = None payout_date: date | None = None
repayment_type: Literal["annuity", "bullet"] | None = None repayment_type: Literal["annuity", "bullet"] | None = None
doppelzaehlung_bestaetigt: bool = False
class InstallmentOut(BaseModel): class InstallmentOut(BaseModel):
@@ -217,8 +266,15 @@ def list_loans(session: Session = Depends(get_session)):
@router.post("/loans", response_model=LoanOut, status_code=201) @router.post("/loans", response_model=LoanOut, status_code=201)
def create_loan(data: LoanIn, session: Session = Depends(get_session)): def create_loan(data: LoanCreate, session: Session = Depends(get_session)):
loan = Loan(**data.model_dump()) fields = data.model_dump(exclude={_FLAG})
if not data.doppelzaehlung_bestaetigt:
konflikte = conflicts_for_loan(session, KreditDaten(
None, fields["name"], fields["principal"], fields["annual_rate_pct"],
fields["term_months"], fields["payout_date"], fields["repayment_type"]))
if konflikte:
raise _doppelzaehlung(konflikte)
loan = Loan(**fields)
session.add(loan) session.add(loan)
session.commit() session.commit()
session.refresh(loan) session.refresh(loan)
@@ -230,8 +286,15 @@ def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_ses
loan = session.get(Loan, loan_id) loan = session.get(Loan, loan_id)
if loan is None: if loan is None:
raise HTTPException(404, "Kredit nicht gefunden") raise HTTPException(404, "Kredit nicht gefunden")
for key, value in data.model_dump(exclude_unset=True).items(): vorher = {k.paar for k in conflicts_for_loan(session, kredit_aus(loan))}
for key, value in data.model_dump(exclude_unset=True, exclude={_FLAG}).items():
setattr(loan, key, value) setattr(loan, key, value)
if not data.doppelzaehlung_bestaetigt:
neu = [k for k in conflicts_for_loan(session, kredit_aus(loan))
if k.paar not in vorher]
if neu:
session.rollback()
raise _doppelzaehlung(neu)
session.commit() session.commit()
session.refresh(loan) session.refresh(loan)
return LoanOut.model_validate(loan) return LoanOut.model_validate(loan)
+3 -11
View File
@@ -13,7 +13,7 @@ from app.db import get_session
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult, from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
Scenario, ScenarioLoan, ScenarioModifier, Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem) ScenarioPlannedItem)
from app.models.views import RESERVED_SCENARIO_NAME from app.reserved import check_reserved_name
from app.services.projection_service import run_projection from app.services.projection_service import run_projection
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"], router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
@@ -86,14 +86,6 @@ def _get_scenario(session: Session, scenario_id: int) -> Scenario:
return scenario return scenario
def _check_reserved_name(name: str | None) -> None:
"""Der Serienname der Ist-Kurve (v_projection) ist für Szenarien tabu."""
if name is not None and (name.strip().casefold()
== RESERVED_SCENARIO_NAME.casefold()):
raise HTTPException(
422, f"Der Name »{RESERVED_SCENARIO_NAME}« ist für die Ist-Kurve reserviert")
@router.get("", response_model=list[ScenarioOut]) @router.get("", response_model=list[ScenarioOut])
def list_scenarios(session: Session = Depends(get_session)): def list_scenarios(session: Session = Depends(get_session)):
return [ScenarioOut.model_validate(s) return [ScenarioOut.model_validate(s)
@@ -102,7 +94,7 @@ def list_scenarios(session: Session = Depends(get_session)):
@router.post("", response_model=ScenarioOut, status_code=201) @router.post("", response_model=ScenarioOut, status_code=201)
def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)): def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
_check_reserved_name(data.name) check_reserved_name(data.name)
if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar(): if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar():
raise HTTPException(409, "Szenario existiert bereits") raise HTTPException(409, "Szenario existiert bereits")
scenario = Scenario(**data.model_dump()) scenario = Scenario(**data.model_dump())
@@ -117,7 +109,7 @@ def patch_scenario(scenario_id: int, data: ScenarioPatch,
session: Session = Depends(get_session)): session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id) scenario = _get_scenario(session, scenario_id)
fields = data.model_dump(exclude_unset=True) fields = data.model_dump(exclude_unset=True)
_check_reserved_name(fields.get("name")) check_reserved_name(fields.get("name"))
if "name" in fields and fields["name"] != scenario.name: if "name" in fields and fields["name"] != scenario.name:
clash = session.execute( clash = session.execute(
select(Scenario).where(Scenario.name == fields["name"])).scalar() select(Scenario).where(Scenario.name == fields["name"])).scalar()
+127
View File
@@ -0,0 +1,127 @@
"""Doppelzählungs-Erkennung Kredit ↔ wiederkehrender Posten (Ausbaustufe 12).
Ein monatlicher Ausgabe-Posten gilt als vermutlich doppelt zu einem Kredit,
wenn sein Betrag innerhalb der Toleranz der regulären Monatsrate liegt und
sich die Zeiträume überschneiden. Bewusst kein Namensvergleich
(„Autokredit" vs. „Rate VW Bank" ist nicht zuverlässig zuordenbar).
Reine Logik auf Wertobjekten, damit auch noch nicht gespeicherte bzw. per
PATCH veränderte Zustände geprüft werden können. Alle Beträge Decimal.
"""
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.engine.loans import add_months, annuity_payment
from app.models.tables import Loan, RecurringItem
CODE = "doppelzaehlung_kredit"
TOLERANZ_PROZENT = Decimal("3")
TOLERANZ_MIN = Decimal("1.00")
CENT = Decimal("0.01")
@dataclass(frozen=True)
class PostenDaten:
id: int | None
name: str
amount: Decimal
rhythm: str
start_date: date | None
end_date: date | None
@dataclass(frozen=True)
class KreditDaten:
id: int | None
name: str
principal: Decimal
annual_rate_pct: Decimal
term_months: int
payout_date: date
repayment_type: str
@dataclass(frozen=True)
class Konflikt:
posten_id: int | None
posten_name: str
posten_betrag: Decimal
kredit_id: int | None
kredit_name: str
kredit_rate: Decimal
@property
def paar(self) -> tuple[int | None, int | None]:
return (self.posten_id, self.kredit_id)
def posten_aus(item: RecurringItem) -> PostenDaten:
return PostenDaten(item.id, item.name, Decimal(item.amount), item.rhythm,
item.start_date, item.end_date)
def kredit_aus(loan: Loan) -> KreditDaten:
return KreditDaten(loan.id, loan.name, Decimal(loan.principal),
Decimal(loan.annual_rate_pct), loan.term_months,
loan.payout_date, loan.repayment_type)
def regular_rate(k: KreditDaten) -> Decimal | None:
"""Reguläre Monatsrate (positiv): Annuität bzw. bei endfälligen Krediten
die monatlichen Zinsen. Ohne reguläre Rate (0 % endfällig, Laufzeit <= 0)
-> None, dann nie Konflikt. Die Schlusszahlung wird nicht betrachtet."""
if k.term_months <= 0:
return None
if k.repayment_type == "bullet":
if k.annual_rate_pct == 0:
return None
return (k.principal * k.annual_rate_pct / Decimal(100) / Decimal(12)
).quantize(CENT, ROUND_HALF_UP)
return annuity_payment(k.principal, k.annual_rate_pct, k.term_months)
def rate_period(k: KreditDaten) -> tuple[date, date]:
"""Erste und letzte Rate, identisch zu engine/loans.loan_schedule."""
return add_months(k.payout_date, 1), add_months(k.payout_date, k.term_months)
def find_conflict(p: PostenDaten, k: KreditDaten) -> Konflikt | None:
if p.rhythm != "monthly" or p.amount >= 0:
return None
rate = regular_rate(k)
if rate is None or rate <= 0:
return None
toleranz = max(TOLERANZ_MIN, rate * TOLERANZ_PROZENT / Decimal(100))
if abs(-p.amount - rate) > toleranz:
return None
erste, letzte = rate_period(k)
if p.start_date is not None and p.start_date > letzte:
return None
if p.end_date is not None and p.end_date < erste:
return None
return Konflikt(p.id, p.name, p.amount, k.id, k.name, rate)
def conflicts_for_recurring(session: Session, p: PostenDaten) -> list[Konflikt]:
loans = session.execute(select(Loan).order_by(Loan.name, Loan.id)).scalars()
return [c for c in (find_conflict(p, kredit_aus(l)) for l in loans) if c]
def conflicts_for_loan(session: Session, k: KreditDaten) -> list[Konflikt]:
items = session.execute(select(RecurringItem)
.order_by(RecurringItem.name, RecurringItem.id)).scalars()
return [c for c in (find_conflict(posten_aus(r), k) for r in items) if c]
def all_conflicts(session: Session) -> list[Konflikt]:
kredite = [kredit_aus(l) for l in session.execute(
select(Loan).order_by(Loan.name, Loan.id)).scalars()]
out: list[Konflikt] = []
for r in session.execute(select(RecurringItem)
.order_by(RecurringItem.name, RecurringItem.id)).scalars():
p = posten_aus(r)
out.extend(c for c in (find_conflict(p, k) for k in kredite) if c)
return out
+5 -4
View File
@@ -2,9 +2,9 @@
Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren): Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren):
wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie
„Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit Vorzeichen, „System-Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit
Semantik wie v_monthly_by_category. Kreditraten brauchen die Annuitätenformel Vorzeichen, Semantik wie v_monthly_by_category. Kreditraten brauchen die
(engine/loans.py) - daher Python + Tabelle statt SQL-View. Annuitätenformel (engine/loans.py) - daher Python + Tabelle statt SQL-View.
""" """
from collections import defaultdict from collections import defaultdict
from datetime import date, timedelta from datetime import date, timedelta
@@ -19,9 +19,10 @@ from app.engine.loans import loan_schedule
from app.engine.recurrence import occurrences from app.engine.recurrence import occurrences
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly, from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
RecurringItem) RecurringItem)
from app.reserved import SYSTEM_PREFIX
UNKATEGORISIERT = "unkategorisiert" UNKATEGORISIERT = "unkategorisiert"
KREDITRATEN = "Kreditraten" KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten"
PLANNING_CHART_LOCK = 110_002 PLANNING_CHART_LOCK = 110_002
+89
View File
@@ -0,0 +1,89 @@
/* Warnfenster bei möglicher Doppelzählung Kredit ↔ Fixposten (Ausbaustufe 12).
*
* Die API antwortet mit 409 und detail.code "doppelzaehlung_kredit", wenn ein
* Posten vermutlich einer Kreditrate entspricht. handle() fängt genau diesen
* Fall im globalen htmx:responseError-Handler ab (alle anderen Fehler laufen
* weiter über den alert) und zeigt den <dialog>. „Trotzdem speichern" markiert
* das auslösende Formular (data-doppelzaehlung-bestaetigt="1") und sendet es
* erneut; die json-form-Extension ergänzt dann doppelzaehlung_bestaetigt=true.
* Nach jedem Request wird die Markierung entfernt, damit ein späteres
* Speichern die Warnung nicht stillschweigend umgeht.
*/
(function () {
'use strict';
var CODE = 'doppelzaehlung_kredit';
var ATTR = 'data-doppelzaehlung-bestaetigt';
var fmt = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function parseDetail(xhr) {
if (!xhr || xhr.status !== 409) { return null; }
var body;
try { body = JSON.parse(xhr.responseText); } catch (e) { return null; }
var d = body && body.detail;
return d && d.code === CODE ? d : null;
}
function eur(s) { return fmt.format(parseFloat(s)) + ' €'; }
function konfliktText(k) {
return 'Posten „' + k.posten + '“ (' + eur(k.posten_betrag) + ') ↔ Kredit „'
+ k.kredit + '“ (Rate ' + eur(k.kredit_rate) + ')';
}
function handle(evt) {
var d = parseDetail(evt && evt.detail && evt.detail.xhr);
var elt = evt && evt.detail && evt.detail.elt;
var form = elt && elt.closest ? elt.closest('form') : null;
if (!d || !form) { return false; }
var doc = form.ownerDocument;
var dlg = doc.getElementById('doppelzaehlung-dialog');
if (!dlg) { return false; }
dlg.querySelector('.dz-meldung').textContent = d.meldung;
var list = dlg.querySelector('.dz-konflikte');
list.textContent = '';
(d.konflikte || []).forEach(function (k) {
var li = doc.createElement('li');
li.textContent = konfliktText(k);
list.appendChild(li);
});
dlg.fbForm = form;
dlg.showModal();
return true;
}
function abbrechen(dlg) {
dlg.fbForm = null;
dlg.close();
}
function bestaetigen(dlg) {
var form = dlg.fbForm;
dlg.fbForm = null;
dlg.close();
if (!form) { return; }
form.setAttribute(ATTR, '1');
form.ownerDocument.defaultView.htmx.trigger(form, 'submit');
}
function init(doc) {
var dlg = doc.getElementById('doppelzaehlung-dialog');
if (!dlg) { return; }
doc.getElementById('dz-abbrechen').addEventListener('click', function () { abbrechen(dlg); });
doc.getElementById('dz-bestaetigen').addEventListener('click', function () { bestaetigen(dlg); });
doc.body.addEventListener('htmx:afterRequest', function (e) {
var elt = e.detail && e.detail.elt;
var form = elt && elt.closest ? elt.closest('form') : null;
if (form && form.getAttribute(ATTR) === '1') {
// Erst nach dem Request mit Flag entfernen: der 409-Request selbst
// hat die Markierung nie gesetzt.
form.removeAttribute(ATTR);
}
});
}
var api = { parseDetail: parseDetail, konfliktText: konfliktText, handle: handle,
abbrechen: abbrechen, bestaetigen: bestaetigen, init: init };
if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
if (typeof window !== 'undefined') { window.FBDoppel = api; }
})();
+10
View File
@@ -226,3 +226,13 @@ label:has(input:disabled), label:has(select:disabled) {
color: inherit; color: inherit;
font-weight: bold; font-weight: bold;
} }
/* Doppelzählung Kredit/Fixposten (Ausbaustufe 12). */
.warn-hint {
display: block;
font-size: 0.8em;
color: #b45309;
}
#doppelzaehlung-dialog {
max-width: 32rem;
}
+17
View File
@@ -7,6 +7,7 @@
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/sort.js"></script> <script src="/static/sort.js"></script>
<script src="/static/doppelzaehlung.js"></script>
</head> </head>
<body> <body>
<nav> <nav>
@@ -26,8 +27,18 @@
<main> <main>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<dialog id="doppelzaehlung-dialog">
<h2>Mögliche Doppelzählung</h2>
<p class="dz-meldung"></p>
<ul class="dz-konflikte"></ul>
<p>Trotzdem speichern?</p>
<button type="button" id="dz-abbrechen" autofocus>Abbrechen</button>
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
</dialog>
<script> <script>
document.body.addEventListener('htmx:responseError', function (e) { document.body.addEventListener('htmx:responseError', function (e) {
// Mögliche Doppelzählung Kredit/Fixposten: eigenes Warnfenster statt alert.
if (window.FBDoppel && window.FBDoppel.handle(e)) { return; }
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status)); alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
}); });
@@ -72,6 +83,10 @@
out[field.name] = field.checked; out[field.name] = field.checked;
}); });
} }
// Bestätigte Doppelzählung (Warnfenster „Trotzdem speichern"):
if (elt.getAttribute && elt.getAttribute('data-doppelzaehlung-bestaetigt') === '1') {
out.doppelzaehlung_bestaetigt = true;
}
xhr.overrideMimeType('text/json'); xhr.overrideMimeType('text/json');
return JSON.stringify(out); return JSON.stringify(out);
} }
@@ -82,6 +97,8 @@
document.getElementById(prefix + '-row-' + id).hidden = editing; document.getElementById(prefix + '-row-' + id).hidden = editing;
document.getElementById(prefix + '-edit-' + id).hidden = !editing; document.getElementById(prefix + '-edit-' + id).hidden = !editing;
} }
if (window.FBDoppel) { window.FBDoppel.init(document); }
</script> </script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer> <footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body> </body>
+22 -1
View File
@@ -87,12 +87,33 @@
Kategorie, der Szenario-Vergleich als Kurven (darin die Ist-Kurve Kategorie, der Szenario-Vergleich als Kurven (darin die Ist-Kurve
„System-Ist“ bis heute, ab heute je Szenario weiter) und das Panel „System-Ist“ bis heute, ab heute je Szenario weiter) und das Panel
„Planung nach Kategorie“ (Monatssummen aus der reinen Planung inkl. „Planung nach Kategorie“ (Monatssummen aus der reinen Planung inkl.
Kreditraten, ohne Szenario-Modifikatoren) – einmal mit demselben „System-Kreditraten“, ohne Szenario-Modifikatoren) – einmal mit demselben
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
Übersicht. Übersicht.
</li> </li>
</ul> </ul>
<p>
<strong>Reservierte Namen:</strong> Kategorien und Szenarien dürfen nicht
mit „System-“ beginnen – dieses Präfix ist für vom Tool erzeugte Einträge
reserviert (z. B. die Ist-Serie „System-Ist“ und die Kreditraten-Serie
„System-Kreditraten“ in Grafana). Ein Versuch, einen eigenen Namen mit
diesem Präfix anzulegen, wird abgelehnt.
</p>
<p>
<strong>Doppelzählungs-Warnung:</strong> beim Anlegen oder Ändern eines
wiederkehrenden Postens oder eines Kredits prüft das Tool, ob ein
monatlicher Ausgabe-Posten ungefähr einer regulären Kreditrate entspricht
(Betrag innerhalb ±3 %, mindestens 1 €, mit überlappendem Zeitraum). Trifft
das zu, erscheint ein Warnfenster – erst ein Klick auf „Trotzdem
speichern“ übernimmt den Eintrag. Solange der Konflikt bestehen bleibt,
zeigt die Planungs-Seite dauerhaft einen ⚠-Hinweis bei den betroffenen
Posten und Krediten an. Am saubersten lässt sich das vermeiden, wenn
Kreditraten ausschließlich über „Kredite“ gepflegt werden statt zusätzlich
als wiederkehrender Posten.
</p>
<p> <p>
<strong>Tabellen sortieren:</strong> ein Klick auf einen Spaltenkopf <strong>Tabellen sortieren:</strong> ein Klick auf einen Spaltenkopf
sortiert die Tabelle danach (⇅ unsortiert, ▲ aufsteigend, ▼ absteigend); sortiert die Tabelle danach (⇅ unsortiert, ▲ aufsteigend, ▼ absteigend);
+2 -2
View File
@@ -12,7 +12,7 @@
<tbody> <tbody>
{% for r in recurring %} {% for r in recurring %}
<tr id="rec-row-{{ r.id }}"> <tr id="rec-row-{{ r.id }}">
<td>{{ r.name }}</td> <td data-sort="{{ r.name }}">{{ r.name }}{% for kn in doppelt_posten.get(r.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „{{ kn }}“</span>{% endfor %}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}" data-sort="{{ r.amount }}">{{ r.amount|eur }} €</td> <td class="{{ 'neg' if r.amount < 0 else '' }}" data-sort="{{ r.amount }}">{{ r.amount|eur }} €</td>
<td>{{ r.rhythm|de_label }}</td> <td>{{ r.rhythm|de_label }}</td>
<td>{{ r.due_day }}</td> <td>{{ r.due_day }}</td>
@@ -197,7 +197,7 @@
<tbody> <tbody>
{% for l in loans %} {% for l in loans %}
<tr id="loan-row-{{ l.id }}"> <tr id="loan-row-{{ l.id }}">
<td>{{ l.name }}</td> <td data-sort="{{ l.name }}">{{ l.name }}{% for pn in doppelt_kredit.get(l.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Posten „{{ pn }}“</span>{% endfor %}</td>
<td data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td> <td data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td>
<td>{{ l.annual_rate_pct|eur }} %</td> <td>{{ l.annual_rate_pct|eur }} %</td>
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td> <td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td>
@@ -0,0 +1,75 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
let JSDOM = null;
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/doppelzaehlung.js'), 'utf8');
const HTML = `
<form id="f" hx-post="/api/recurring"><input name="name" value="Rate Auto"></form>
<dialog id="doppelzaehlung-dialog">
<p class="dz-meldung"></p><ul class="dz-konflikte"></ul>
<button type="button" id="dz-abbrechen">Abbrechen</button>
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
</dialog>`;
const BODY = JSON.stringify({ detail: { code: 'doppelzaehlung_kredit', meldung: 'Erklärung',
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-100.00', kredit: 'Auto', kredit_rate: '100.00' }] } });
function load() {
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
const w = dom.window;
const calls = [];
w.htmx = { trigger: (el, name) => calls.push([el.id, name]) };
const dlg = w.document.getElementById('doppelzaehlung-dialog');
dlg.showModal = function () { this.setAttribute('open', ''); };
dlg.close = function () { this.removeAttribute('open'); };
w.eval(SRC);
w.FBDoppel.init(w.document);
const form = w.document.getElementById('f');
const evt = { detail: { xhr: { status: 409, responseText: BODY }, elt: form } };
return { w, dlg, form, evt, calls };
}
test('409 öffnet Dialog mit Erklärung und Konfliktliste', { skip: !JSDOM }, () => {
const { w, dlg, evt } = load();
assert.equal(w.FBDoppel.handle(evt), true);
assert.ok(dlg.hasAttribute('open'));
assert.equal(dlg.querySelector('.dz-meldung').textContent, 'Erklärung');
assert.equal(dlg.querySelectorAll('.dz-konflikte li').length, 1);
});
test('andere Fehler werden nicht abgefangen', { skip: !JSDOM }, () => {
const { w, form } = load();
assert.equal(w.FBDoppel.handle({ detail: { xhr: { status: 422, responseText: '{}' }, elt: form } }), false);
});
test('Abbrechen speichert nicht', { skip: !JSDOM }, () => {
const { w, dlg, form, evt, calls } = load();
w.FBDoppel.handle(evt);
w.document.getElementById('dz-abbrechen').click();
assert.ok(!dlg.hasAttribute('open'));
assert.equal(calls.length, 0);
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
assert.equal(form.querySelector('input').value, 'Rate Auto');
});
test('Trotzdem speichern sendet erneut mit Flag', { skip: !JSDOM }, () => {
const { w, dlg, form, evt, calls } = load();
w.FBDoppel.handle(evt);
w.document.getElementById('dz-bestaetigen').click();
assert.ok(!dlg.hasAttribute('open'));
assert.equal(form.getAttribute('data-doppelzaehlung-bestaetigt'), '1');
assert.deepEqual(calls, [['f', 'submit']]);
});
test('Flag nach Request entfernt', { skip: !JSDOM }, () => {
const { w, form, evt } = load();
w.FBDoppel.handle(evt);
w.document.getElementById('dz-bestaetigen').click();
const e = new w.CustomEvent('htmx:afterRequest', { bubbles: true, detail: { elt: form } });
form.dispatchEvent(e);
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
});
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const D = require('../../app/static/doppelzaehlung.js');
const body = { detail: { code: 'doppelzaehlung_kredit', meldung: 'M',
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-1234.50', kredit: 'Auto', kredit_rate: '1234.50' }] } };
test('parseDetail erkennt nur 409 mit Code', () => {
assert.equal(D.parseDetail({ status: 409, responseText: JSON.stringify(body) }).code, 'doppelzaehlung_kredit');
assert.equal(D.parseDetail({ status: 409, responseText: '{"detail":"Kategorie existiert bereits"}' }), null);
assert.equal(D.parseDetail({ status: 422, responseText: JSON.stringify(body) }), null);
assert.equal(D.parseDetail({ status: 409, responseText: 'kein json' }), null);
assert.equal(D.parseDetail(null), null);
});
test('konfliktText deutsch formatiert', () => {
assert.equal(D.konfliktText(body.detail.konflikte[0]),
'Posten „Rate Auto“ (-1.234,50 €) ↔ Kredit „Auto“ (Rate 1.234,50 €)');
});
+99
View File
@@ -0,0 +1,99 @@
from datetime import date
from decimal import Decimal
import pytest
from app.models.tables import Loan, RecurringItem
from app.services.double_count import (KreditDaten, PostenDaten, all_conflicts,
conflicts_for_loan, conflicts_for_recurring,
find_conflict, rate_period, regular_rate)
D = Decimal
# 1.200 € zu 0 % auf 12 Monate, Auszahlung 15.01.2026 -> Rate 100,00;
# erste Rate 15.02.2026, letzte 15.01.2027.
K100 = KreditDaten(1, "Auto", D("1200.00"), D("0"), 12, date(2026, 1, 15), "annuity")
def P(amount, rhythm="monthly", start=None, end=None, name="Rate"):
return PostenDaten(7, name, D(amount), rhythm, start, end)
def test_regular_rate_und_zeitraum():
assert regular_rate(K100) == D("100.00")
assert rate_period(K100) == (date(2026, 2, 15), date(2027, 1, 15))
annu = KreditDaten(2, "A", D("5000.00"), D("6.0"), 48, date(2026, 1, 1), "annuity")
assert regular_rate(annu) == D("117.43")
bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet")
assert regular_rate(bullet) == D("60.00")
bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet")
assert regular_rate(bullet0) is None
@pytest.mark.parametrize("amount,hit", [
("-100.00", True), ("-103.00", True), ("-103.01", False),
("-97.00", True), ("-96.99", False),
])
def test_toleranz_drei_prozent(amount, hit):
assert (find_conflict(P(amount), K100) is not None) is hit
@pytest.mark.parametrize("amount,hit", [("-21.00", True), ("-21.01", False), ("-19.00", True)])
def test_mindesttoleranz_ein_euro(amount, hit):
k20 = KreditDaten(5, "Klein", D("240.00"), D("0"), 12, date(2026, 1, 15), "annuity")
assert (find_conflict(P(amount), k20) is not None) is hit
def test_endfaellig_zinsrate_und_nullzins():
bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet")
assert find_conflict(P("-60.00"), bullet) is not None
bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet")
assert find_conflict(P("-60.00"), bullet0) is None
assert find_conflict(P("-0.50"), bullet0) is None
def test_nur_monatliche_ausgaben():
assert find_conflict(P("-100.00", rhythm="quarterly"), K100) is None
assert find_conflict(P("100.00"), K100) is None
@pytest.mark.parametrize("start,end,hit", [
(None, date(2026, 2, 14), False), (None, date(2026, 2, 15), True),
(date(2027, 1, 16), None, False), (date(2027, 1, 15), None, True),
(None, None, True), (date(2025, 1, 1), date(2030, 1, 1), True),
])
def test_zeitraeume(start, end, hit):
assert (find_conflict(P("-100.00", start=start, end=end), K100) is not None) is hit
def test_konflikt_inhalt():
k = find_conflict(P("-101.50", name="Rate Auto"), K100)
assert (k.posten_id, k.posten_name, k.posten_betrag) == (7, "Rate Auto", D("-101.50"))
assert (k.kredit_id, k.kredit_name, k.kredit_rate) == (1, "Auto", D("100.00"))
assert k.paar == (7, 1)
def _seed(db):
db.add_all([
Loan(name="Auto", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
payout_date=date(2026, 1, 15), repayment_type="annuity"),
Loan(name="Möbel", principal=D("240.00"), annual_rate_pct=D("0"), term_months=12,
payout_date=date(2026, 1, 15), repayment_type="annuity"),
RecurringItem(name="Rate Auto", amount=D("-100.00"), rhythm="monthly", due_day=15),
RecurringItem(name="Rate Möbel", amount=D("-20.00"), rhythm="monthly", due_day=15),
RecurringItem(name="Miete", amount=D("-600.00"), rhythm="monthly", due_day=1),
])
db.commit()
def test_all_conflicts_findet_paare_sortiert(db):
_seed(db)
assert [(k.posten_name, k.kredit_name) for k in all_conflicts(db)] == [
("Rate Auto", "Auto"), ("Rate Möbel", "Möbel")]
def test_conflicts_for_recurring_und_loan(db):
_seed(db)
neu = PostenDaten(None, "Neu", D("-99.00"), "monthly", None, None)
assert [k.kredit_name for k in conflicts_for_recurring(db, neu)] == ["Auto"]
kredit = KreditDaten(None, "Neu", D("7200.00"), D("0"), 12, date(2026, 1, 1), "annuity")
assert [k.posten_name for k in conflicts_for_loan(db, kredit)] == ["Miete"]
+91
View File
@@ -0,0 +1,91 @@
from decimal import Decimal
H = {"Authorization": "Bearer test-key"}
KREDIT = {"name": "Auto", "principal": "1200.00", "annual_rate_pct": "0",
"term_months": 12, "payout_date": "2026-01-15", "repayment_type": "annuity"}
POSTEN = {"name": "Rate Auto", "amount": "-100.00", "rhythm": "monthly", "due_day": 15}
def _count(client, path):
return len(client.get(path, headers=H).json())
def test_posten_zu_bestehendem_kredit_409(client):
assert client.post("/api/loans", headers=H, json=KREDIT).status_code == 201
r = client.post("/api/recurring", headers=H, json=POSTEN)
assert r.status_code == 409
d = r.json()["detail"]
assert d["code"] == "doppelzaehlung_kredit"
assert "doppelt" in d["meldung"]
assert d["konflikte"] == [{"posten": "Rate Auto", "posten_betrag": "-100.00",
"kredit": "Auto", "kredit_rate": "100.00"}]
assert _count(client, "/api/recurring") == 0
def test_posten_mit_bestaetigung_gespeichert(client):
client.post("/api/loans", headers=H, json=KREDIT)
r = client.post("/api/recurring", headers=H,
json={**POSTEN, "doppelzaehlung_bestaetigt": True})
assert r.status_code == 201
assert "doppelzaehlung_bestaetigt" not in r.json()
assert _count(client, "/api/recurring") == 1
def test_kredit_zu_bestehendem_posten_409_und_bestaetigung(client):
client.post("/api/recurring", headers=H, json=POSTEN)
r = client.post("/api/loans", headers=H, json=KREDIT)
assert r.status_code == 409
assert r.json()["detail"]["konflikte"][0]["posten"] == "Rate Auto"
assert _count(client, "/api/loans") == 0
r = client.post("/api/loans", headers=H, json={**KREDIT, "doppelzaehlung_bestaetigt": True})
assert r.status_code == 201
assert "doppelzaehlung_bestaetigt" not in r.json()
def test_ohne_konflikt_unveraendert(client):
client.post("/api/loans", headers=H, json=KREDIT)
r = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"})
assert r.status_code == 201
def test_patch_posten_erzeugt_konflikt_409_ohne_aenderung(client):
client.post("/api/loans", headers=H, json=KREDIT)
item = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"}).json()
r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"amount": "-100.00"})
assert r.status_code == 409
stored = client.get("/api/recurring", headers=H).json()[0]
assert Decimal(stored["amount"]) == Decimal("-50.00")
r = client.patch(f"/api/recurring/{item['id']}", headers=H,
json={"amount": "-100.00", "doppelzaehlung_bestaetigt": True})
assert r.status_code == 200
assert Decimal(r.json()["amount"]) == Decimal("-100.00")
def test_patch_nur_name_bei_bestehendem_konflikt_ohne_flag(client):
client.post("/api/loans", headers=H, json=KREDIT)
item = client.post("/api/recurring", headers=H,
json={**POSTEN, "doppelzaehlung_bestaetigt": True}).json()
r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"name": "Rate Auto neu"})
assert r.status_code == 200
loan = client.get("/api/loans", headers=H).json()[0]
r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"name": "Auto neu"})
assert r.status_code == 200
def test_patch_kredit_erzeugt_konflikt_409(client):
client.post("/api/recurring", headers=H, json=POSTEN)
loan = client.post("/api/loans", headers=H, json={**KREDIT, "principal": "2400.00"}).json()
r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"principal": "1200.00"})
assert r.status_code == 409
assert Decimal(client.get("/api/loans", headers=H).json()[0]["principal"]) == Decimal("2400.00")
r = client.patch(f"/api/loans/{loan['id']}", headers=H,
json={"principal": "1200.00", "doppelzaehlung_bestaetigt": True})
assert r.status_code == 200
def test_vorschlag_uebernehmen_nutzt_denselben_endpunkt(client):
# „Vorschlag übernehmen" sendet dieselben Felder (inkl. start_date/category_id=null).
client.post("/api/loans", headers=H, json=KREDIT)
r = client.post("/api/recurring", headers=H, json={
**POSTEN, "start_date": None, "category_id": None})
assert r.status_code == 409
+43
View File
@@ -588,3 +588,46 @@ def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"}) client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/planung"): for path in ("/", "/planung"):
assert client.get(path).status_code == 200, path assert client.get(path).status_code == 200, path
def test_base_hat_doppelzaehlungs_dialog(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text
assert '<script src="/static/doppelzaehlung.js"></script>' in html
assert '<dialog id="doppelzaehlung-dialog">' in html
assert 'id="dz-abbrechen" autofocus' in html
assert ">Trotzdem speichern</button>" in html
assert "FBDoppel.handle(e)" in html
assert "doppelzaehlung_bestaetigt = true" in html
def test_planung_zeigt_doppelzaehlungs_hinweis(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add_all([
Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
term_months=12, payout_date=date(2026, 1, 15), repayment_type="annuity"),
RecurringItem(name="Rate Auto", amount=Decimal("-100.00"), rhythm="monthly", due_day=15),
RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1),
])
db.commit()
html = client.get("/planung").text
assert "⚠ zählt evtl. doppelt mit Kredit „Auto“" in html
assert "⚠ zählt evtl. doppelt mit Posten „Rate Auto“" in html
assert html.count("⚠ zählt evtl. doppelt") == 2
assert '<td data-sort="Rate Auto">' in html
assert '<td data-sort="Auto">' in html
def test_planung_ohne_konflikt_kein_hinweis(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1))
db.commit()
assert "zählt evtl. doppelt" not in client.get("/planung").text
def test_vorschlag_formular_nutzt_json_form_post(client):
# Review Focus 5: „Vorschlag übernehmen" läuft über denselben Warnweg.
import re
from pathlib import Path
src = Path("app/templates/planning.html").read_text(encoding="utf-8")
assert re.search(r'class="inline-form" hx-ext="json-form" hx-post="/api/recurring"', src)
+50
View File
@@ -0,0 +1,50 @@
import pytest
from app.models.views import RESERVED_SCENARIO_NAME
from app.reserved import SYSTEM_PREFIX, is_reserved_name
from app.services.planning_chart import KREDITRATEN
H = {"Authorization": "Bearer test-key"}
def test_systemnamen_leiten_sich_vom_praefix_ab():
assert SYSTEM_PREFIX == "System-"
assert RESERVED_SCENARIO_NAME == "System-Ist"
assert KREDITRATEN == "System-Kreditraten"
@pytest.mark.parametrize("name,expected", [
("System-X", True), ("system-kreditraten", True), (" SYSTEM-Ist ", True),
("Kreditraten", False), ("Systemkosten", False), ("Ist-System", False),
("System", False),
])
def test_is_reserved_name(name, expected):
assert is_reserved_name(name) is expected
@pytest.mark.parametrize("name", ["System-X", "system-kreditraten", " SYSTEM-Ist "])
def test_kategorie_post_reserviert(client, name):
r = client.post("/api/categories", headers=H, json={"name": name})
assert r.status_code == 422
assert r.json()["detail"] == ("Namen, die mit »System-« beginnen, sind für "
"vom Tool erzeugte Einträge reserviert")
def test_kategorie_patch_reserviert(client):
cat = client.post("/api/categories", headers=H, json={"name": "Normal"}).json()
r = client.patch(f"/api/categories/{cat['id']}", headers=H, json={"name": "System-Test"})
assert r.status_code == 422
names = [c["name"] for c in client.get("/api/categories", headers=H).json()]
assert "Normal" in names and "System-Test" not in names
@pytest.mark.parametrize("name", ["Kreditraten", "Systemkosten", "Ist-System"])
def test_kategorie_normale_namen_erlaubt(client, name):
assert client.post("/api/categories", headers=H, json={"name": name}).status_code == 201
@pytest.mark.parametrize("name", ["System-Plan", "system-x"])
def test_szenario_praefix_reserviert(client, name):
r = client.post("/api/scenarios", headers=H, json={"name": name})
assert r.status_code == 422
assert "reserviert" in r.json()["detail"]