Compare commits

..
30 Commits
Author SHA256 Message Date
rdmsandClaude Opus 5.5 4c3fa25cd0 docs: A14 Plan-Haken
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:43:46 +02:00
rdmsandClaude Opus 5.5 d51287e8d8 fix: Final-Review A14 – Beispielbetrag ersetzt, Vorzeichen-Hinweis Neuer Betrag, Laufzeit max 1200, Mermaid
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:39:05 +02:00
rdmsandClaude Opus 5.5 eb68781a1d chore: Release v0.14.0 (Szenario-Zeitraum, Neuer Betrag, Szenario-Zahlungen)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:30:02 +02:00
rdmsandClaude Haiku 4.5 30330801cc fix: Tabellen scrollen auf schmalen Bildschirmen in sich (A14-T6 Fixrunde)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-30 13:28:19 +02:00
rdmsandClaude Haiku 4.5 dfb814955f fix: Navigation bricht auf schmalen Bildschirmen um, gewähltes Tortensegment oben, Legendenzusatz nicht fett (A14-T6)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-30 13:16:57 +02:00
rdmsandClaude Haiku 4.5 94fd7ab96e fix: Kredit-Ausgabe ohne Laufzeit-Grenze (A14-T5 Fixrunde)
LoanOut erbt nicht mehr von LoanIn - definiert term_months ohne Grenzen.
Altbestände mit term_months>1200 (vor Validierung direkt eingefügt)
crashen GET /api/loans und PATCH nicht mehr.

Regression-Test: test_altbestand_kredit_mit_laufzeit_ausserhalb_grenzen
- Inseriert Loan mit term_months=1500 direkt via DB
- Bestätigt GET /api/loans und Ausgabe-Serialisierung funktioniert
- Keine Validierung auf Output

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-30 13:14:14 +02:00
rdmsandClaude Haiku 4.5 80b8b369c7 refactor: Tilgungsplan-Helfer schedule_for, Kredit-Laufzeit 1–1200 Monate (A14-T5)
- Neuer schedule_for() in app/engine/loans.py: vereinheitlicht Tilgungsplan-Erstellung
- Laufzeit-Validierung in LoanIn und LoanPatch: 1–1200 Monate
- Aufrufstellen aktualisiert (routers/planning.py, services/breakdown.py,
  services/projection_service.py, services/planning_chart.py)
- Keine direkten loan_schedule(Decimal(...)) Aufrufe außerhalb engine/loans.py mehr
- Tests für schedule_for und Laufzeit-Grenzen (1, 1200, sowie 0/-5/1201 Ablehnung)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-30 13:10:55 +02:00
rdmsandClaude Opus 5.5 cd7ba6aa6a feat: Szenarien-Seite mit zwei Formularen, Zeitraum-Spalte und wiederkehrenden Szenario-Zahlungen (A14-T4)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:08:16 +02:00
rdmsandClaude Opus 5.5 8911623c15 fix: Migration e7f1a9c3b214 auch auf SQLite lauffähig (A14-T1 Fixrunde)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:06:36 +02:00
rdmsandClaude Opus 5.5 2aed685d7e feat: API für Szenario-Zeitraum, Neuer Betrag und szenario-eigene wiederkehrende Zahlungen (A14-T3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:03:16 +02:00
rdmsandClaude Opus 5.5 aaf05b9209 feat: Szenario-Einträge je Stichtag mit Zeitraum und Art Neuer Betrag (A14-T2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:00:18 +02:00
rdmsandClaude Opus 5.5 b0a1ba80a1 feat: Szenario-Zeitraum, Neuer Betrag, szenario-eigene Zahlungen – Datenmodell + Migration (A14-T1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:58:06 +02:00
rdmsandClaude Opus 5.5 c065ade0d5 docs: Plan Ausbaustufe 14
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:17:50 +02:00
rdmsandClaude Opus 5.5 d8dc25f2a8 docs: Spec Ausbaustufe 14 (Szenario-Zeitraum, Neuer Betrag, szenario-eigene wiederkehrende Zahlungen)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:59:44 +02:00
rdmsandClaude Opus 5.5 60cd3c4fea fix: Versionskennung an statischen Dateien gegen veraltete Browser-Caches (v0.13.1)
Gecachte style.css ohne Tortenfarben ließ alle Segmente schwarz erscheinen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 17:54:55 +02:00
rdmsandClaude Opus 5.5 23557db0fb docs: A13 Plan-Haken gesetzt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 17:01:38 +02:00
rdmsandClaude Opus 5.5 987e28847c fix: Aufteilung – Final-Review-Nachträge (Monat <1000, Übrige-Mitglieder, Auth-Test, Doku) (A13)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:56:57 +02:00
rdmsandClaude Opus 5.5 b32f28fff5 chore: Release v0.13.0 (Seite Aufteilung)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:49:50 +02:00
rdmsandClaude Opus 5.5 3ef20034d3 feat: Aufteilung – Drill-down in Kategorie per Klick auf Tortensegment (A13-T6)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:45:44 +02:00
rdmsandClaude Opus 5.5 4b6144d812 fix: Aufteilung – Tabelle volle Breite, Torte darunter; ungültige Eingaben inline (A13-T5)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:29:24 +02:00
rdmsandClaude Opus 5.5 2a62bdca6c feat: Seite Aufteilung – Auswahl, Kennzahlen, Tabelle, Tortendiagramm (A13-T5)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:24:20 +02:00
rdmsandClaude Opus 5.5 1cded22e90 feat: Service compute_breakdown – System-Plan/Szenario, Doppelzählungs-Hinweis (A13-T4)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:20:38 +02:00
rdmsandClaude Opus 5.5 9e804cd349 fix: Torte – große Segmente als zwei Teilbögen (A13-T3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:16:49 +02:00
rdmsandClaude Opus 5.5 125d370497 feat: Engine pie – Tortensegmente mit Übrige-Regel und SVG-Pfaden (A13-T3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:13:11 +02:00
rdmsandClaude Opus 5.5 7555d8e6a4 feat: Engine breakdown – Monatsbeträge je Posten, Summen, Kategorie-Filter (A13-T2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:09:30 +02:00
rdmsandClaude Opus 5.5 0f5bb45a55 refactor: Modifikator-Helfer öffentlich, load_scenario_inputs aus run_projection (A13-T1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:05:43 +02:00
rdmsandClaude Opus 5.5 acabc3d2a4 docs: A13 – höchstens 8 benannte Tortensegmente (Nutzerentscheidung)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:41:21 +02:00
rdmsandClaude Opus 5.5 c015b52a70 docs: Plan Ausbaustufe 13 (8 Tasks)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:49:02 +02:00
rdmsandClaude Opus 5.5 a5f2f23a7e docs: Design Ausbaustufe 13 – Seite Aufteilung (Monatskosten je Kategorie)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:27:58 +02:00
rdmsandClaude Opus 5.5 d2b21c8f8b fix: Balkendiagramme ohne Segment-Beschriftung, Tooltip mit allen Kategorien, Monat ohne Uhrzeit
In gestapelten Balken mit vielen Kategorien überlagerten sich die
Segment-Beschriftungen, sichtbar blieb ein zufälliger kleiner Wert.
Betrifft Monat nach Kategorie und Planung nach Kategorie.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:29:55 +02:00
50 changed files with 6583 additions and 221 deletions
+72 -16
View File
@@ -18,17 +18,17 @@ direkt aus `routers/gui.py`.
```mermaid ```mermaid
flowchart TD flowchart TD
subgraph TPL["templates (Jinja2, app/templates/)"] subgraph TPL["templates (Jinja2, app/templates/)"]
T_pages["base.html, login.html, index.html<br/>transactions.html, salden.html<br/>import.html, planning.html<br/>admin.html, hilfe.html"] T_pages["base.html, login.html, index.html<br/>transactions.html, salden.html<br/>import.html, planning.html<br/>admin.html, hilfe.html<br/>aufteilung.html"]
end end
subgraph ROUTERS["routers/"] subgraph ROUTERS["routers/"]
R_gui["gui.py<br/>gui_session, index, buchungen,<br/>salden_page, import_page, planung_page"] R_gui["gui.py<br/>gui_session, index, buchungen,<br/>salden_page, import_page, planung_page,<br/>aufteilung_page, aufteilung_kategorie_fragment"]
R_accounts["accounts.py<br/>list_accounts, create_account,<br/>patch_account"] R_accounts["accounts.py<br/>list_accounts, create_account,<br/>patch_account"]
R_transactions["transactions.py<br/>list_transactions, create_transaction,<br/>patch_transaction, count_transactions"] R_transactions["transactions.py<br/>list_transactions, create_transaction,<br/>patch_transaction, count_transactions"]
R_categories["categories.py<br/>list_categories, create_category_rule"] R_categories["categories.py<br/>list_categories, create_category_rule"]
R_imports["imports.py<br/>upload, scan_inbox, preview,<br/>confirm, rollback"] R_imports["imports.py<br/>upload, scan_inbox, preview,<br/>confirm, rollback"]
R_planning["planning.py<br/>list_recurring, list_planned,<br/>list_loans, recurring_suggestions"] R_planning["planning.py<br/>list_recurring, list_planned,<br/>list_loans, recurring_suggestions"]
R_scenarios["scenarios.py<br/>create_scenario, add_modifier,<br/>project_scenario"] R_scenarios["scenarios.py<br/>create_scenario, add_modifier,<br/>add_scenario_recurring,<br/>project_scenario"]
R_admin["admin.py<br/>admin_page, admin_change_password,<br/>apply_category_rules"] R_admin["admin.py<br/>admin_page, admin_change_password,<br/>apply_category_rules"]
end end
@@ -37,7 +37,8 @@ flowchart TD
S_balances["balances.py<br/>account_balance(at), total_balance"] S_balances["balances.py<br/>account_balance(at), total_balance"]
S_categorize["categorize.py<br/>apply_rules"] S_categorize["categorize.py<br/>apply_rules"]
S_suggestions["suggestions.py<br/>suggest_recurring"] S_suggestions["suggestions.py<br/>suggest_recurring"]
S_projection["projection_service.py<br/>run_projection, refresh_stale_projections"] S_projection["projection_service.py<br/>run_projection, load_scenario_inputs,<br/>refresh_stale_projections"]
S_breakdown["breakdown.py<br/>compute_breakdown"]
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"] S_doublecount["double_count.py<br/>all_conflicts, conflicts_for_recurring,<br/>conflicts_for_loan"]
@@ -46,10 +47,12 @@ flowchart TD
N_reserved["app/reserved.py<br/>check_reserved_name"] 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, schedule_for,<br/>annuity_payment, add_months"]
E_recurrence["recurrence.py<br/>occurrences"] E_recurrence["recurrence.py<br/>occurrences"]
E_projection["projection.py<br/>project"] E_projection["projection.py<br/>project"]
E_scenario["scenario.py<br/>build_cashflows (PlainRecurring/<br/>PlainPlanned/PlainModifier)"] E_scenario["scenario.py<br/>build_cashflows, modified_amount<br/>(Stichtag on, gilt_am)<br/>PlainRecurring/PlainPlanned/<br/>PlainModifier"]
E_breakdown["breakdown.py<br/>breakdown_rows, summarize,<br/>category_rows, share"]
E_pie["pie.py<br/>pie_slices"]
end end
subgraph PARSERS["parsers/"] subgraph PARSERS["parsers/"]
@@ -64,9 +67,9 @@ flowchart TD
end end
subgraph MODELS["models/ + alembic/"] subgraph MODELS["models/ + alembic/"]
M_tables["tables.py<br/>Account, Statement, Transaction,<br/>RecurringItem, PlannedItem, Loan,<br/>Scenario, ProjectionResult, ..."] M_tables["tables.py<br/>Account, Statement, Transaction,<br/>RecurringItem, PlannedItem, Loan,<br/>Scenario, ScenarioRecurringItem,<br/>ProjectionResult, ..."]
M_views["views.py — create_views()<br/>v_balance_history, v_balance_total,<br/>v_monthly_by_category, v_projection"] M_views["views.py — create_views()<br/>v_balance_history, v_balance_total,<br/>v_monthly_by_category, v_projection"]
M_alembic["alembic/versions<br/>b2b1f5a18a74_initial_schema<br/>1ef6a356f028_konto_anker"] M_alembic["alembic/versions<br/>b2b1f5a18a74_initial_schema<br/>1ef6a356f028_konto_anker<br/>e7f1a9c3b214_szenario_zeitraum"]
end end
R_gui --> T_pages R_gui --> T_pages
@@ -94,6 +97,9 @@ flowchart TD
R_scenarios --> N_reserved R_scenarios --> N_reserved
R_categories --> N_reserved R_categories --> N_reserved
R_gui --> S_doublecount R_gui --> S_doublecount
R_gui --> S_breakdown
R_gui --> E_breakdown
R_gui --> E_pie
R_admin --> S_admin R_admin --> S_admin
ROUTERS -.-> M_tables ROUTERS -.-> M_tables
@@ -106,6 +112,10 @@ flowchart TD
S_projection --> E_projection S_projection --> E_projection
S_projection --> E_scenario S_projection --> E_scenario
S_projection --> S_balances S_projection --> S_balances
S_breakdown --> S_projection
S_breakdown --> E_breakdown
S_breakdown --> E_loans
S_breakdown --> S_doublecount
S_planning_chart --> E_loans S_planning_chart --> E_loans
S_planning_chart --> E_recurrence S_planning_chart --> E_recurrence
S_planning_chart --> M_tables S_planning_chart --> M_tables
@@ -113,6 +123,9 @@ flowchart TD
E_scenario --> E_loans E_scenario --> E_loans
E_scenario --> E_recurrence E_scenario --> E_recurrence
E_breakdown --> E_scenario
E_breakdown --> E_recurrence
E_breakdown --> E_loans
P_registry --> P_vr P_registry --> P_vr
P_registry --> P_hvb P_registry --> P_hvb
@@ -166,7 +179,7 @@ sequenceDiagram
IM->>IM: _find_or_create_account(bank, iban) IM->>IM: _find_or_create_account(bank, iban)
IM->>DB: INSERT Statement(status="draft") IM->>DB: INSERT Statement(status="draft")
opt Saldo pruefbar (PDF immer; CSV nur VR: balance_checkable=True) opt Saldo pruefbar (PDF immer, CSV nur VR: balance_checkable=True)
IM->>VA: balance_difference(parsed) IM->>VA: balance_difference(parsed)
Note right of IM: Differenz != 0 -> Statement.status="error", Abbruch Note right of IM: Differenz != 0 -> Statement.status="error", Abbruch
end end
@@ -304,15 +317,36 @@ nur noch über `rollback` (löscht Statement + Transaktionen komplett) zu
entfernen, nicht mehr über `DELETE /api/imports/{id}` (nur für Entwürfe und Fehl-Importe). entfernen, nicht mehr über `DELETE /api/imports/{id}` (nur für Entwürfe und Fehl-Importe).
**Szenario-Engine ist rein.** `app/engine/*` (`loans.py`, `recurrence.py`, **Szenario-Engine ist rein.** `app/engine/*` (`loans.py`, `recurrence.py`,
`projection.py`, `scenario.py`) hat **keinen** Datenbank- oder `projection.py`, `scenario.py`, `breakdown.py`, `pie.py`) hat **keinen**
Request-Zugriff — nur `scenario.py` importiert innerhalb der Engine von Datenbank- oder Request-Zugriff — `scenario.py` und `breakdown.py`
`loans.py`/`recurrence.py`, sonst keine Abhängigkeiten auf `app.*`. Ein-/ importieren innerhalb der Engine (`loans.py`/`recurrence.py`/`scenario.py`),
sonst keine Abhängigkeiten auf `app.*`. Ein-/
Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`, Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`,
`PlainPlanned`, `PlainModifier`, `Projection`). `services/projection_service.py` `PlainPlanned`, `PlainModifier`, `Projection`). `services/projection_service.py`
ist die einzige Brücke: es lädt ORM-Objekte (`Loan`, `RecurringItem`, ist die einzige Brücke: `load_scenario_inputs(session, scenario)` ist die
`PlannedItem`, `Scenario`, `ScenarioModifier`), baut daraus die Plain-Objekte gemeinsame Ladefunktion (`None` = System-Plan, ohne Szenario-Änderungen)
und lädt ORM-Objekte (`Loan`, `RecurringItem`,
`PlannedItem`, `Scenario`, `ScenarioModifier`, `ScenarioRecurringItem` — Letztere
als `PlainRecurring` mit `id=None`), baut daraus die Plain-Objekte
und ruft `engine.scenario.build_cashflows` → `engine.projection.project`. und ruft `engine.scenario.build_cashflows` → `engine.projection.project`.
**Szenario-Einträge mit Zeitraum (Ausbaustufe 14).** `ScenarioModifier.start_date`/
`end_date` sind „Gültig ab/bis" (inklusiv, leer = unbegrenzt; bei Art `ende`
zählt nur `end_date` als Enddatum). Neue Art `set` („Neuer Betrag", nur für
Posten) ersetzt den Betrag. `engine.scenario.modified_amount(..., on=)` wertet
die Einträge mit einem Stichtag je Fälligkeit aus — `build_cashflows` nimmt
jedes Fälligkeitsdatum, `engine.breakdown.breakdown_rows` über
`engine.recurrence.due_in_month` das Fälligkeitsdatum im Monat; `gilt_am`
prüft den Zeitraum, mehrere treffende Einträge wirken in Anlage-Reihenfolge.
`ScenarioRecurringItem` (Tabelle `scenario_recurring_items`, API
`/api/scenarios/{id}/recurring`) sind wiederkehrende Zahlungen nur in einem
Szenario: sie zählen unabhängig von `include_recurring` und werden von
Posten-Einträgen nie getroffen (`PlainRecurring.id=None`).
`ScenarioPlannedItem.category_id` ordnet Szenario-Einmalzahlungen einer
Kategorie zu. Die Szenarien-Seite (`szenarien.html`) nutzt dafür
`static/szenarien.js` (`window.FBSzenario`). Migration:
`e7f1a9c3b214_szenario_zeitraum`.
**`v_projection` (Ausbaustufe 11) — System-Ist + Szenarien ab heute.** **`v_projection` (Ausbaustufe 11) — System-Ist + Szenarien ab heute.**
`models/views.py::VIEWS["v_projection"]` liefert für den Grafana-Szenario- `models/views.py::VIEWS["v_projection"]` liefert für den Grafana-Szenario-
Vergleich drei Teile in einer Tabelle: die Ist-Gesamtsaldo-Serie unter dem Vergleich drei Teile in einer Tabelle: die Ist-Gesamtsaldo-Serie unter dem
@@ -352,6 +386,26 @@ Formular (`data-doppelzaehlung-bestaetigt="1"`) und sendet es erneut, die
einen dauerhaften ⚠-Hinweis je betroffenem Posten/Kredit in `planning.html`, einen dauerhaften ⚠-Hinweis je betroffenem Posten/Kredit in `planning.html`,
solange der Konflikt fortbesteht. solange der Konflikt fortbesteht.
**Aufteilung (Ausbaustufe 13).** `/aufteilung` (`routers/gui.py::aufteilung_page`,
GET-Formular, die Auswahl steht in der URL) zeigt für einen Monat die Kosten
je Kategorie aus reiner Planung, nie aus Ist-Buchungen.
`services/breakdown.py::compute_breakdown` lädt die Planung über
`load_scenario_inputs`; `PLAN_NAME` („System-Plan") entspricht
`load_scenario_inputs(session, None)`, ein Szenario wird mit seinen
Änderungen und Krediten gerechnet. Die Rechenregeln stehen in
`engine/breakdown.py` (`breakdown_rows`, `summarize`, `category_rows`,
`share`): Fixposten werden per `recurrence.STEP` auf den Monat normalisiert
(vierteljährlich ÷ 3, jährlich ÷ 12), Einmalposten und Kreditraten zählen
voll im Fälligkeitsmonat, eine Kredit-Auszahlung nie als Einkunft; `FREI`
(„Frei verfügbar") ist der Rest der Einkünfte. Das Tortendiagramm entsteht
serverseitig in `engine/pie.py::pie_slices` (`MIN_SHARE` 3 %, `MAX_NAMED` 8,
Sammelsegment `UEBRIGE`). Drill-down: `GET /aufteilung/kategorie`
(`aufteilung_kategorie_fragment`) liefert die Posten der Kategorie und
tauscht die Haupttorte per `hx-swap-oob` aus (gewähltes Segment markiert);
`sammel=1` wählt die Sammelgruppe „Übrige". Der Doppelzählungs-Hinweis nutzt
`services/double_count.py::all_conflicts`. Keine Tabelle, keine View,
Grafana unberührt.
**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
@@ -378,8 +432,10 @@ markiert und sortiert stattdessen serverseitig über alle Seiten
**`VERSION`-Datei als Single Source.** `finance/VERSION` (ein Einzeiler, z.B. **`VERSION`-Datei als Single Source.** `finance/VERSION` (ein Einzeiler, z.B.
`0.4.0`) ist die einzige Versionsquelle: `app/version.py::get_version()` `0.4.0`) ist die einzige Versionsquelle: `app/version.py::get_version()`
liest sie (Fallback `"0.0.0-dev"`), gespeist in den Jinja2-Footer liest sie (Fallback `"0.0.0-dev"`), gespeist in den Jinja2-Footer
(`templates.env.globals["app_version"]` in `routers/gui.py`) und (`templates.env.globals["app_version"]` in `routers/gui.py`), als
`GET /api/version` (`main.py`). `Containerfile` kopiert `VERSION` ins Image; Cache-Busting-Parameter an allen statischen Dateien (`/static/style.css?v=…`
usw. in `base.html`/`login.html` — ohne ihn behalten Browser alte CSS/JS
über ein Release hinaus) und `GET /api/version` (`main.py`). `Containerfile` kopiert `VERSION` ins Image;
`create_pod_finance.sh` liest dieselbe Datei für den Image-Tag `create_pod_finance.sh` liest dieselbe Datei für den Image-Tag
(`API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"`). Ein (`API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"`). Ein
Release ist somit: `VERSION` hochzählen → Image bauen → Redeploy. Release ist somit: `VERSION` hochzählen → Image bauen → Redeploy.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,244 @@
# Design — Ausbaustufe 13: Seite „Aufteilung" (Monatskosten je Kategorie) (v0.13.0)
> Status: Design im Chat vom Nutzer freigegeben (2026-09-29), Spec zur
> Durchsicht. Anlass: In der Beratung soll für einen gewählten Monat
> anschaulich werden, welche monatlichen Kosten je Kategorie anfallen und
> welcher Anteil der Einkünfte danach frei verfügbar bleibt — für die reine
> Planung ebenso wie für ein Szenario.
## Nutzerentscheidungen (2026-09-29)
- **Neuer Menüpunkt „Aufteilung"** (zwischen „Szenarien" und „Admin").
- **Datenbasis immer Planung, nie Ist-Buchungen.** Auch vergangene Monate
werden aus den damals gültigen Planungsdaten berechnet (Fixposten,
Einmalposten, Kredite). Die Auswahl heißt deshalb **„System-Plan"**
(nicht „System-Ist" — das bleibt echten Buchungen vorbehalten). Der Name
fällt unter den reservierten Präfix `System-` (Ausbaustufe 12), kann also
nicht mit einem echten Szenario kollidieren.
- **Szenarien** sind nur für den laufenden und künftige Monate wählbar;
„System-Plan" für jeden Monat.
- **Fixposten normalisiert** auf den Monat: monatlich ×1, quartalsweise ×⅓,
jährlich ×1/12.
- **Einmalposten mit vollem Betrag**, nur im Monat ihrer Fälligkeit (keine
Verteilung über 12 Monate).
- **Filter** „Fixposten", „Einmalposten", „Kredite" wirken auf Einnahmen
**und** Ausgaben gleichermaßen.
- **Tabelle flach**: eine Zeile je Posten mit Kategorie-Spalte, per
`sort.js` sortierbar.
- **Tortendiagramm direkt auf der Seite** (serverseitiges SVG), **kein
Grafana-Panel**.
- **Torte = Einkünfte**: Kostensegmente je Kategorie + Segment „Frei
verfügbar". Bei Unterdeckung wechselt die Basis auf die Gesamtkosten (mit
deutlichem Hinweis); die Tabelle bleibt bei „% der Einkünfte".
- **„Frei verfügbar" als Tabellenzeile** im `<tfoot>` (bei Unterdeckung
„Unterdeckung", rot). Darunter „Gesamte Einkünfte".
- **Kategorien < 3 %** der Tortenbasis werden nur in der Torte zu „Übrige"
zusammengefasst. Zusätzlich (Nachtrag 2026-09-29): **höchstens 8 benannte
Kategorien** + „Übrige" + „Frei verfügbar" = max. 10 Segmente
(8-Farben-Referenzpalette, validiert).
- **Endfällige Kredite**: Die Schlussrate enthält die volle Tilgung und
erscheint im letzten Monat voll (konsequent zu „Einmalposten voll").
- **Drill-down**: Klick auf ein Kostensegment (oder dessen Legendeneintrag)
zeigt unter der Haupttorte eine zweite Torte mit allen Posten dieser
Kategorie. Hinweistext „Für Kategorie-Aufteilung auf Segment klicken".
- **Version v0.13.0** (neue Seite).
## Befund (Ist-Zustand, v0.12.0)
- `engine/scenario.py`: `build_cashflows` liefert nur `(Datum, Betrag)` —
ohne Name/Kategorie/Art, daher für die Aufteilung nicht verwendbar.
Modifikator-Logik steckt in den privaten Helfern `_modified` und
`_effective_end`.
- `services/projection_service.py::run_projection` lädt Fixposten
(`include_recurring`), globale Einmalposten (`include_planned`),
`ScenarioPlannedItem`s (immer, ohne Kategorie), Szenario-Kredite
(`ScenarioLoan`) und Modifikatoren inline — keine wiederverwendbare
Ladefunktion.
- `services/planning_chart.py` rechnet die reine Planung (alle Kredite,
keine Modifikatoren) mit Kategorie `KREDITRATEN = "System-Kreditraten"`
und `UNKATEGORISIERT = "unkategorisiert"`.
- `services/double_count.py::all_conflicts` liefert `Konflikt`
(`posten_id`, `kredit_id`, Namen, Beträge) über **alle** Kredite.
- `static/sort.js` sortiert nur `<tbody>`; `<tfoot>` bleibt stehen.
- htmx ist in `base.html` eingebunden.
## Architektur
Kernachse bleibt `routers → services → engine`. Keine Schemaänderung, keine
Migration, keine View, Grafana unverändert.
| Einheit | Aufgabe |
|---|---|
| `engine/scenario.py` | `_modified` → öffentlich `modified_amount`, `_effective_end` → `effective_end` (reine Umbenennung, `build_cashflows` nutzt die neuen Namen). |
| `engine/breakdown.py` (neu, rein) | `BreakdownRow` (Dataclass: `name`, `category`, `kind`, `rhythm \| None`, `amount: Decimal`, vorzeichenbehaftet), `Filters` (Dataclass: `recurring`, `planned`, `loans: bool`), `breakdown_rows(...)`, `summarize(rows)`, `category_rows(rows, kategorie, small)`. |
| `engine/pie.py` (neu, rein) | `pie_slices(values, min_share)` → Segmente mit Label, Betrag, Anteil, Start-/Endwinkel, SVG-Pfad `d`; fasst Anteile < `min_share` zu „Übrige" zusammen; Sonderfall ein Segment = Vollkreis. |
| `services/projection_service.py` | Neu `load_scenario_inputs(session, scenario) → ScenarioInputs` (Fixposten, Einmalposten inkl. Szenario-Einmalposten, Kredite als `(Name, Tilgungsplan, Auszahlung)`, Modifikatoren). `run_projection` nutzt sie; Verhalten unverändert. |
| `services/breakdown.py` (neu) | `compute_breakdown(session, month, scenario_id \| None, filters) → Breakdown`. `None` = System-Plan: alle `RecurringItem`, alle `PlannedItem`, alle `Loan`, keine Modifikatoren, keine Szenario-Einmalposten. Sonst `load_scenario_inputs`. Setzt Kategorie-IDs in Namen um. Ermittelt Doppelzählungs-Hinweise (siehe unten). |
| `routers/gui.py` | `aufteilung_page` (`GET /aufteilung`) und `aufteilung_kategorie_fragment` (`GET /aufteilung/kategorie`). |
| `templates/aufteilung.html`, `templates/_aufteilung_torte.html`, `templates/_aufteilung_kategorie.html` (neu) | Seite, Haupttorte (Partial) bzw. Fragment der zweiten Torte. |
| `templates/base.html` | Menüpunkt „Aufteilung". |
| `templates/hilfe.html` | Abschnitt „Aufteilung". |
| `static/style.css` | Layout Tabelle/Torte (nebeneinander, schmal untereinander), Tortenfarben als Tokens (hell/dunkel). |
## Rechenregeln (`engine/breakdown.py`)
Monat M = erster bis letzter Kalendertag. Alle Beträge `Decimal`; jede Zeile
wird auf Cent gerundet (`ROUND_HALF_UP`), Summen entstehen aus den
gerundeten Zeilen.
**Fixposten** (nur wenn `filters.recurring`; bei Szenario zusätzlich nur mit
`include_recurring` — das erledigt `load_scenario_inputs`):
- Zählt, wenn `start_date` leer oder ≤ Monatsende **und** effektives Ende
(`effective_end`: eigenes `end_date` bzw. früheres Ende aus
`ende`-Modifikator) leer oder ≥ Monatsanfang.
- Betrag = `modified_amount(amount, category_id, id, modifiers)`;
`None` (Modifikator `remove`) ⇒ Posten entfällt.
- Monatsbetrag = Betrag × Faktor (`monthly` 1, `quarterly` 1/3, `yearly`
1/12), gerundet.
- `kind = "recurring"`, `rhythm` gesetzt.
**Einmalposten** (nur wenn `filters.planned`):
- Posten mit `due` in M, voller Betrag nach `modified_amount(amount,
category_id, None, modifiers)`.
- Bei Szenario: globale `PlannedItem`s nur mit `include_planned`,
`ScenarioPlannedItem`s immer (Kategorie `None` ⇒ „unkategorisiert").
- `kind = "planned"`.
**Kredite** (nur wenn `filters.loans`):
- System-Plan: alle Kredite; Szenario: nur zugeordnete (`ScenarioLoan`).
- Eine Zeile je Kredit: Name = Kreditname, Kategorie =
`KREDITRATEN` („System-Kreditraten"), Betrag = Summe `payment` aller
Raten mit `due` in M (bereits negativ). Keine Rate in M ⇒ keine Zeile.
- Auszahlung zählt **nicht**. Endfällige Schlussrate zählt voll.
- `kind = "loan"`.
**Allgemein:**
- Zeilen mit Betrag 0 entfallen.
- Kategorie-Name: `Category.name`, sonst „unkategorisiert"
(`planning_chart.UNKATEGORISIERT`).
**`summarize(rows)` → `Summary`:**
- `income` = Σ positive Beträge; `costs` = Σ |negative Beträge|;
`free = income − costs` (negativ ⇒ Unterdeckung).
- `share(row)` = |Betrag| ÷ `income` × 100, eine Nachkommastelle
(`ROUND_HALF_UP`), nur für Kostenzeilen; `income == 0` ⇒ `None`
(Anzeige „–").
- `category_costs`: Kosten je Kategorie (positiv), absteigend.
- `pie_basis`: `income`, wenn `income ≥ costs` und `income > 0`; sonst
`costs` (Unterdeckung oder keine Einkünfte).
- `pie_values`: Kategorienkosten, plus „Frei verfügbar" = `free`, wenn
`pie_basis == income` und `free > 0`.
**`category_rows(rows, kategorie, small)`:** Kostenzeilen der Kategorie;
für `kategorie == "Übrige"` die Kostenzeilen aller Kategorien in `small`
(die in der Haupttorte zu „Übrige" zusammengefassten). Einnahmezeilen
erscheinen nie in einer Torte.
## Torte (`engine/pie.py`)
- `pie_slices(values: list[tuple[str, Decimal]], min_share=Decimal("0.03"))`:
Anteil = Wert ÷ Summe; Werte mit Anteil < `min_share` werden zu einem
Segment „Übrige" zusammengefasst (nur wenn mindestens zwei betroffen
sind — ein einzelnes kleines Segment bleibt, sonst hieße es sinnlos
„Übrige"). „Frei verfügbar" wird nie zusammengefasst.
- Rückgabe je Segment: `label`, `value`, `share`, `path` (SVG-`d` im
Einheitskreis-Koordinatensystem, `large-arc`-Flag korrekt ab 180°),
`members` (bei „Übrige": die zusammengefassten Labels).
- Genau ein Segment ⇒ `path = None`, Template zeichnet `<circle>`.
- Winkelrechnung darf `float` verwenden (reine Geometrie, keine Beträge);
Beträge und Anteile bleiben `Decimal`.
## Seite `/aufteilung`
**Query-Parameter:** `monat=JJJJ-MM` (Default laufender Monat),
`szenario=<id>` (leer = System-Plan), `fixposten=1`, `einmal=1`,
`kredite=1`, `gesendet=1` (unterscheidet Erstaufruf — alle Filter an — von
„alle Häkchen entfernt"), `kategorie=<Name>` (optional, Drill-down).
**Aufbau von oben nach unten:**
1. **Formular** (GET, immer sichtbar): `<input type="month">`, `<select>`
„System-Plan" + alle Szenarien (alphabetisch), drei Checkboxen,
Button „Anzeigen". Gewählter Monat zusätzlich als MM.JJJJ angezeigt.
2. **Kennzahlen**: Einkünfte · Kosten · Frei verfügbar / Unterdeckung (rot).
3. **Hinweise** (inline):
- Fehler: ungültiger Monat, unbekanntes Szenario, Szenario + Monat vor
dem laufenden Monat („Szenarien sind erst ab dem laufenden Monat
wählbar. Für vergangene Monate „System-Plan" wählen.") ⇒ keine
Tabelle/Torte, Formular mit der Auswahl bleibt.
- ⚠ Doppelzählung: je `Konflikt` aus `all_conflicts`, dessen Fixposten
**und** Kredit beide als Zeile in der aktuellen Aufteilung stehen:
„⚠ Fixposten „X" zählt evtl. doppelt mit Kredit „Y"".
- Unterdeckung: „Kosten übersteigen die Einkünfte um X € — das
Diagramm zeigt die Aufteilung der Gesamtkosten."
- Keine Zeilen: „Für diese Auswahl gibt es keine Posten."
4. **Tabelle** (`sort.js`, clientseitig): Posten · Kategorie · Art
(„Fixposten (monatlich|quartalsweise|jährlich)", „Einmalposten",
„Kredit") · Monatsbetrag (vorzeichenbehaftet, `neg`-Klasse wie in
`planning.html`) · % der Einkünfte (leer bei Einnahmen, „–" bei
Einkünften 0). Anfangssortierung: Monatsbetrag aufsteigend (größte
Kosten oben). `<tfoot>`: Zeile „Frei verfügbar" bzw. „Unterdeckung"
(rot) mit Betrag und Anteil. Direkt unter der Tabelle: „Gesamte
Einkünfte: X €".
5. **Haupttorte** (SVG) mit Legende (Label, Betrag, %). Jedes Segment hat
`<title>` (Label, Betrag, %). „Frei verfügbar" in eigener, klar
unterscheidbarer Farbe. Farbpalette per `dataviz`-Skill festgelegt,
hell/dunkel lesbar. Darunter Text „Für Kategorie-Aufteilung auf Segment
klicken".
6. **Drill-down-Container** `#aufteilung-kategorie`: leer, oder bei
gesetztem `kategorie` bereits die zweite Torte.
**Drill-down:**
- Kostensegmente und ihre Legendeneinträge sind Links
`href="/aufteilung?<aktuelle Auswahl>&kategorie=<Name>"` mit
`hx-get="/aufteilung/kategorie?<…>"`, `hx-target="#aufteilung-kategorie"`,
`hx-push-url="<href>"`. Ohne JS funktioniert der Link als Seitenaufruf.
- „Frei verfügbar" ist kein Link.
- Gewähltes Segment in der Haupttorte hervorgehoben (Klasse `gewaehlt`,
Umriss). Die Haupttorte liegt in einem eigenen Partial
`_aufteilung_torte.html` (Container `#aufteilung-torte`); das
Fragment-Response von `/aufteilung/kategorie` enthält sie zusätzlich mit
`hx-swap-oob="true"` und markiertem Segment. Kein eigenes JS.
- Zweite Torte: Überschrift „<Kategorie>: X € / Monat"; Segmente = Posten
(`category_rows`), Basis = Kategoriekosten, 3-%-Regel wie oben;
Legende: Posten, Monatsbetrag, % der Kategorie, % der Einkünfte.
- Unbekannte oder leere Kategorie (z. B. alte URL, Filter geändert):
Hinweis „Kategorie „X" hat in dieser Auswahl keine Kosten." statt
Fehler.
- `GET /aufteilung/kategorie` nimmt dieselben Parameter, validiert
identisch und liefert nur `_aufteilung_kategorie.html` (Fehler als
Hinweis im Fragment, Status 200).
Beide Routen: `Depends(gui_session)`, kein `_safe_refresh` (nichts
Abgeleitetes zu schreiben — reine Berechnung beim Aufruf).
## Tests
| Datei | Prüft |
|---|---|
| `tests/test_scenario_engine.py` | Bestehende Tests grün nach Umbenennung `modified_amount`/`effective_end`. |
| `tests/test_breakdown.py` (neu, ohne DB) | Normalisierung monatlich/quartalsweise/jährlich inkl. Rundung; Laufzeitgrenzen (Start am Monatsletzten, Ende am Monatsersten, Ende im Vormonat); `ende`-Modifikator; `remove`/`percent`/`absolute`; Einmalposten in/außerhalb M; Annuitätenrate; endfällige Zins-/Schlussrate; Auszahlung zählt nicht; Filter wirken auf Einnahmen; 0-€-Zeilen entfallen; `summarize` (Anteile, Unterdeckung, Einkünfte 0, `pie_basis`, „Frei verfügbar" nur ohne Unterdeckung); `category_rows` inkl. „Übrige" und Kategorie ohne Kosten. |
| `tests/test_pie.py` (neu) | Summe der Winkel = 360°; `large-arc`-Flag; „Übrige" < 3 % (nur ab zwei Segmenten); „Frei verfügbar" nie zusammengefasst; Einzelsegment ⇒ `path is None`; leere Eingabe ⇒ leere Liste. |
| `tests/test_projection.py` / `test_projection_refresh.py` | Grün nach Refactoring `load_scenario_inputs` (Verhaltensgleichheit). |
| `tests/test_breakdown_service.py` (neu, DB wie bestehende Service-Tests) | System-Plan: alle Kredite, keine Modifikatoren/Szenario-Einmalposten; Szenario: nur zugeordnete Kredite, `include_recurring`/`include_planned`, `ScenarioPlannedItem` als „unkategorisiert"; Kategorienamen; Doppelzählungs-Hinweis nur, wenn beide Zeilen enthalten. |
| `tests/test_gui.py` | Menüpunkt; Default-Auswahl; `gesendet=1` ohne Filter ⇒ „keine Posten"; `<tfoot>`-Zeile; Unterdeckungs-Hinweis; Szenario + Vormonat, ungültiger Monat, unbekanntes Szenario ⇒ Hinweis inline, Formular vorhanden, Status 200; Drill-down-Fragment; Seite mit `kategorie` enthält zweite Torte; unbekannte Kategorie ⇒ Hinweis; „Frei verfügbar" ohne Link. |
| `tests/test_sort_templates.py` | Tabelle der Aufteilung ist sortierbar, `<tfoot>` außerhalb `<tbody>`. |
Testdaten sind synthetisch (DATENSCHUTZ: keine Werte aus Fixtures oder
Live-DB).
## Abschluss
- `docs/ARCHITEKTUR.md`: neue Module im Komponenten-Diagramm, Konzept-
absatz „Aufteilung (Ausbaustufe 13)".
- `hilfe.html`: Abschnitt „Aufteilung".
- `finance/VERSION` → `0.13.0`, `./create_pod_finance.sh`, Live-Check.
- Fable-Testagent-Gate je Task; letzter Task als Live-Test gegen den
laufenden Pod (inkl. Klick-Drill-down im Browser).
- Ledger-Einträge in `.superpowers/sdd/progress.md`.
## Nicht Teil dieser Ausbaustufe (YAGNI)
- Grafana-Panel / Snapshot-Tabelle.
- Ist-Buchungen als Datenbasis.
- Verteilung von Einmalposten über mehrere Monate.
- Export (CSV/PDF) der Aufteilung.
@@ -0,0 +1,188 @@
# Design — Ausbaustufe 14: Szenario-Einträge mit Zeitraum, „Neuer Betrag", szenario-eigene wiederkehrende Zahlungen (v0.14.0)
> Status: vom Nutzer im Chat freigegeben (2026-09-30), Abschnitte 1–3.
> Umsetzungsplan folgt unter `docs/superpowers/plans/`. Datenschutz-Regel aus
> `CLAUDE.md` gilt: konkrete Posten-Namen/Beträge aus der Live-Datenbank
> stehen nur im Chat, nicht in diesem Dokument, nicht in Tests/Commits/Ledger.
## Anlass
Der Nutzer wollte in einem Szenario einen bestehenden monatlichen Fixposten
ab einem Stichtag auf 50 % senken. Das ging nicht: Szenario-Einträge
(Prozent/Absolut/Entfällt) wirken über die gesamte Laufzeit, „Absolut" ist
eine Kürzung (nicht ein neuer Betrag), und ein Szenario kann keine eigenen
wiederkehrenden Zahlungen haben — nur Einmalzahlungen. Die Lücke ist eine
bewusste Auslassung aus A5 („Ende" als eigene Art statt Gültigkeitszeitraum
aller Modifikatoren).
## Nutzerentscheidungen (Chat 2026-09-30)
- **A — Zeitraum:** Prozent, Kürzung (€), Neuer Betrag und Entfällt bekommen
optionale Grenzen **Gültig ab** und **Gültig bis** (beide leer =
unbegrenzt). „Ende" bleibt wie bisher.
- **Neue Eintragsart „Neuer Betrag"** (ersetzt den Betrag eines Postens).
„Absolut" heißt in der GUI künftig **„Kürzung (€)"**.
- **B — szenario-eigene wiederkehrende Zahlungen** mit denselben Feldern wie
Fixposten inkl. **optionaler Kategorie**; Kategorie-Einträge treffen sie.
- **Formular geteilt** in „Neue Änderung an Bestehendem" und „Neue Zahlung
nur in diesem Szenario" (Szenario-Einmalzahlungen bekommen dabei ebenfalls
eine optionale Kategorie).
- **A14-Kandidaten** aus dem Ledger werden mitgenommen, **außer**
„figcaption nicht fett" — die Bildunterschrift der Torte bleibt fett
(ausdrücklicher Nutzerwunsch).
- Das Live-Szenario des Nutzers wird **nicht** vom Tool-Team angepasst; der
Nutzer probiert die Neuerung selbst daran aus.
## Teil 1 — Datenmodell (eine Alembic-Revision, `down_revision = 'd5e8b3c7a410'`)
- `scenario_modifiers`: neue Spalte `start_date DATE NULL` („Gültig ab").
Die vorhandene Spalte `end_date` bedeutet künftig bei **allen** Arten
„Gültig bis" (inklusiv); bei `ende` bleibt sie das Enddatum des Postens.
Bestandsdaten: bisherige `percent`/`absolute`/`remove`-Zeilen haben
`end_date = NULL` (API hat es bisher genullt) → gelten unverändert
unbegrenzt. **Keine Datenmigration nötig.**
- Neue Art `set` (GUI-Label „Neuer Betrag"): `value` ist der neue Betrag
**mit Vorzeichen** (wie Fixposten-Beträge, z. B. `-137.50`). Nur mit
`target_type = "recurring"` zulässig.
- Neue Tabelle `scenario_recurring_items`: `id`, `scenario_id` (FK
`scenarios.id`, index), `name String(200)`, `amount MONEY`,
`rhythm String(20)`, `due_day Integer`, `start_date Date NULL`,
`end_date Date NULL`, `category_id` (FK `categories.id`, NULL). Modell
`ScenarioRecurringItem` in `models/tables.py`.
- `scenario_planned_items`: neue Spalte `category_id` (FK `categories.id`,
NULL).
- **Downgrade:** löscht `set`-Zeilen, setzt `end_date = NULL` für alle
Nicht-`ende`-Zeilen (alte Semantik), droppt `start_date`,
`scenario_planned_items.category_id` und `scenario_recurring_items`.
## Teil 2 — Rechenlogik (`engine/scenario.py`, `engine/breakdown.py`)
- `PlainModifier` bekommt `start_date: date | None = None`.
- `modified_amount(amount, category_id, recurring_id, modifiers, on: date)`:
ein Modifikator wirkt nur, wenn `(start_date is None or on >= start_date)
and (end_date is None or on <= end_date)` (bei Art `ende` wird er hier wie
bisher übersprungen — Ende wirkt nur über `effective_end`). Reihenfolge
unverändert = Anlage-Reihenfolge (ID). `set` → `amount = value`; ein
späterer `percent`/`absolute` rechnet auf dem neuen Betrag weiter.
`remove` im Zeitraum → `None` (Fälligkeit entfällt), außerhalb → normal.
- **Projektion** (`build_cashflows`): Betrag **je Fälligkeit** mit
`on = Fälligkeitsdatum` bestimmen (bisher einmal je Posten).
Einmalposten: `on = due`.
- **Aufteilung** (`breakdown_rows`): Stichtag je Fixposten = sein
Fälligkeitstag im gewählten Monat, auf die Monatslänge geklemmt (gleiche
Klemmregel wie `recurrence.occurrences`); Einmalposten: `on = due`.
Monatsausschluss über `start_date`/`effective_end` bleibt wie bisher.
Beispiel: Posten fällig am 14., Eintrag „50 % ab 16.10." → Oktober voll,
ab November halbiert.
- **Szenario-eigene wiederkehrende Zahlungen** werden in
`load_scenario_inputs` als `PlainRecurring` mit **`id = None`** geladen
(Typ `id: int | None`), damit Posten-Einträge (`target_type="recurring"`)
sie nie treffen — die IDs zweier Tabellen könnten kollidieren.
Kategorie-Einträge treffen sie über `category_id`. Sie zählen **immer**,
unabhängig von `include_recurring` (Regel wie Szenario-Einmalzahlungen).
Für „System-Plan" (`scenario=None`) gibt es sie nicht.
- Szenario-Einmalzahlungen werden mit ihrer `category_id` geladen (bisher
fest `None`).
- `BreakdownRow.recurring_id` bleibt für szenario-eigene Zahlungen `None` →
der Doppelzählungs-Hinweis (Kredit vs. Fixposten) berücksichtigt sie
nicht.
- Unberührt: Grafana „Planung nach Kategorie", „System-Plan", v_projection.
## Teil 3 — API (`routers/scenarios.py`)
- `ModifierIn`: `kind: Literal["percent", "absolute", "set", "remove",
"ende"]`, neu `start_date: date | None = None`. Validator (422, deutsch):
- `ende` ohne `end_date` → „Art »Ende« erfordert ein Datum" (wie bisher);
bei `ende` wird `start_date` genullt.
- `set` mit `target_type != "recurring"` → „Art »Neuer Betrag« ist nur für
Posten möglich".
- `start_date > end_date` → „»Gültig ab« liegt nach »Gültig bis«".
- `end_date` wird **nicht mehr** für Nicht-`ende`-Arten genullt.
POST/PATCH bleiben Full-Body.
- Neuer Endpunkt `/api/scenarios/{id}/recurring`: `GET` (Liste, sortiert
nach Name), `POST` (201), `PATCH /{item_id}` (Full-Body), `DELETE
/{item_id}` (204). Schema `ScenarioRecurringIn` mit den Feldern und
Validierungen von `RecurringIn` (Rhythmus-Literal, `due_day` 1–31, Ende
nicht vor Start); Kategorie-Existenz via `_check_category`-Muster (404
„Kategorie nicht gefunden"); fremde/fehlende IDs → 404 „Wiederkehrende
Zahlung nicht gefunden"; fehlendes Szenario → 404. Keine
Doppelzählungs-Prüfung.
- `ScenarioPlannedIn`: neu `category_id: int | None = None` mit
Kategorie-Existenzprüfung.
- `delete_scenario` löscht zusätzlich `ScenarioRecurringItem`-Zeilen.
## Teil 4 — GUI (`templates/szenarien.html`, `routers/gui.py`, `formats.py`)
- `de_label`: `absolute → "Kürzung (€)"`, neu `set → "Neuer Betrag"`.
`modifier_kinds` = `percent, absolute, set, remove, ende`.
- **Tabelle „Einträge"**, Spalten: Was | Art | Wert/Betrag | Zeitraum | (Aktionen).
- Modifikator-Zeilen: Wert bei `percent`/`absolute`/`set`; Zeitraum
„ab TT.MM.JJJJ", „bis TT.MM.JJJJ", „TT.MM.JJJJ – TT.MM.JJJJ" oder „–";
bei `ende` das Enddatum.
- Neue Zeilen für szenario-eigene wiederkehrende Zahlungen: Name (+
Kategorie), Art „Wiederkehrend (monatlich, am 14.)", Betrag, Zeitraum
Start–Ende; Inline-Bearbeiten/Löschen (Muster Einmalzahlung).
- Einmalzahlungs-Zeilen zeigen ggf. die Kategorie; Edit-Zeile bekommt das
Kategorie-Feld.
- **Formular „Neue Änderung an Bestehendem"** (POST `/modifiers`): Art
(Prozent | Kürzung (€) | Neuer Betrag | Entfällt | Ende), Ziel-Typ,
Kategorie/Posten, Wert, Gültig ab, Gültig bis.
- Wert-Label dynamisch: „Prozentsatz (%)" / „Kürzung (€)" / „Neuer
Betrag (€)"; bei Entfällt/Ende gesperrt.
- Neuer Betrag: Ziel-Typ fest auf Posten (Option Kategorie gesperrt).
- Ende: „Gültig ab" gesperrt, „Gültig bis" heißt „Endet am".
- **Formular „Neue Zahlung nur in diesem Szenario"**: Art (Einmalzahlung |
Wiederkehrend), Name, Betrag, Kategorie (optional, „(keine)"), Fällig am
(nur Einmal), Rhythmus + Fälligkeitstag + Start + Ende (nur
Wiederkehrend). POST-Ziel wechselt `/planned` ↔ `/recurring` (Muster
`data-planned-url`).
- Modifikator-Edit-Zeilen bekommen „Gültig ab"/„Gültig bis" und dieselbe
Umschaltlogik.
- UX-Regel bleibt: nicht passende Felder sichtbar + `disabled` + Tooltip,
Fehler inline über den bestehenden Response-Error-Handler.
- `hilfe.html`: Szenario-Absatz um Zeitraum, Neuer Betrag, Kürzung (€) und
wiederkehrende Zahlungen ergänzen, mit neutralem Beispiel („ein Posten
wird ab 16.10. halbiert").
## Teil 5 — A14-Kandidaten aus dem Ledger
1. **Nav-Überlauf bei 390 px:** `nav` darf umbrechen (`flex-wrap: wrap`,
Abstände), kein horizontales Scrollen der Seite.
2. **Umriss des gewählten Tortensegments zuletzt zeichnen:** gewähltes
Segment wird im SVG als letztes Element ausgegeben, damit sein Umriss
nicht von Nachbarn überdeckt wird.
3. **Tilgungsplan-Helfer:** die 4 Aufrufe `loan_schedule(Decimal(l.principal),
Decimal(l.annual_rate_pct), l.term_months, l.payout_date,
l.repayment_type)` (planning-Router, services/breakdown,
projection_service, planning_chart) über einen Helfer
`schedule_for(loan)` führen — verhaltensneutral.
4. **`.legende-zusatz` ohne CSS:** eigene Regel (normales Gewicht,
gedämpfte Farbe), damit der Zusatz bei gewählter Legendenzeile nicht mit
fett wird.
5. **`term_months`-Validierung** 1..1200 für Kredite (Anlegen + PATCH),
422 inline.
Ausdrücklich **nicht**: figcaption der Torte entfetten.
## Teil 6 — Tests, Doku, Release
- **Engine:** Grenzen inklusiv (Tag vor/an/nach ab bzw. bis), `set` dann
`percent`, `remove` mit Zeitraum, szenario-eigene Zahlung von
Kategorie-Eintrag getroffen / von Posten-Eintrag mit gleicher ID nicht,
zählt bei `include_recurring = false`; Aufteilungs-Stichtag (Okt voll /
Nov halbiert, Klemmung am Monatsende) — nur erfundene Beträge.
- **API:** alle 422/404-Fälle aus Teil 3, CRUD `/recurring`,
Szenario-Löschen räumt `/recurring` mit ab, Planned mit Kategorie,
`term_months`-Grenzen.
- **Migration:** Upgrade/Downgrade in Wegwerf-Postgres; Bestandseinträge
bleiben gleich.
- **GUI/JS:** Umschaltmatrix beider Formulare und der Edit-Zeilen (Stil der
bestehenden Template-/JS-Tests), Nav-CSS, Torten-Reihenfolge.
- Suite bleibt grün (Basis: 377 passed / 3 skipped).
- **Doku:** `docs/ARCHITEKTUR.md` (neue Tabelle, Endpunkt, Stichtag-Regel),
`CLAUDE.md` falls betroffen, fb-Repo (finanz-api- und
finanzberatung-Skill).
- **Ablauf:** Plan `docs/superpowers/plans/2026-09-30-ausbaustufe-14.md`,
direkt auf `main`; jeder Task mit Fable-Testagent-Gate und
Ledger-Eintrag; Release **v0.14.0** (VERSION, Backup, Redeploy,
Live-Smoke, Fable-Release-Gate).
+1 -1
View File
@@ -1 +1 @@
0.12.0 0.14.0
@@ -0,0 +1,56 @@
"""szenario zeitraum, neuer betrag, szenario-eigene wiederkehrende zahlungen
Revision ID: e7f1a9c3b214
Revises: d5e8b3c7a410
Create Date: 2026-09-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'e7f1a9c3b214'
down_revision: Union[str, Sequence[str], None] = 'd5e8b3c7a410'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
FK_PLANNED_CATEGORY = 'fk_scenario_planned_items_category_id'
def upgrade() -> None:
# Bestandsdaten: percent/absolute/remove haben end_date NULL (API hat es
# bisher genullt) und gelten damit unverändert unbegrenzt.
op.add_column('scenario_modifiers', sa.Column('start_date', sa.Date(), nullable=True))
# Batch-Modus: SQLite kennt kein ALTER von Constraints (Tabelle wird neu
# angelegt); auf Postgres entspricht das den direkten ALTER-Befehlen.
with op.batch_alter_table('scenario_planned_items') as batch:
batch.add_column(sa.Column('category_id', sa.Integer(), nullable=True))
batch.create_foreign_key(FK_PLANNED_CATEGORY, 'categories',
['category_id'], ['id'])
op.create_table(
'scenario_recurring_items',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('scenario_id', sa.Integer(), sa.ForeignKey('scenarios.id'), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('rhythm', sa.String(length=20), nullable=False),
sa.Column('due_day', sa.Integer(), nullable=False),
sa.Column('start_date', sa.Date(), nullable=True),
sa.Column('end_date', sa.Date(), nullable=True),
sa.Column('category_id', sa.Integer(), sa.ForeignKey('categories.id'), nullable=True),
)
op.create_index('ix_scenario_recurring_items_scenario_id',
'scenario_recurring_items', ['scenario_id'])
def downgrade() -> None:
# Alte Semantik: "set" gab es nicht, end_date nur bei "ende".
op.execute("DELETE FROM scenario_modifiers WHERE kind = 'set'")
op.execute("UPDATE scenario_modifiers SET end_date = NULL WHERE kind <> 'ende'")
op.drop_index('ix_scenario_recurring_items_scenario_id', 'scenario_recurring_items')
op.drop_table('scenario_recurring_items')
with op.batch_alter_table('scenario_planned_items') as batch:
batch.drop_column('category_id')
op.drop_column('scenario_modifiers', 'start_date')
+130
View File
@@ -0,0 +1,130 @@
"""Aufteilung (Ausbaustufe 13): Monatsbeträge je Posten für einen Monat.
Rein (kein DB-Zugriff). Fixposten werden auf den Monat normalisiert
(quartalsweise /3, jährlich /12), Einmalposten und Kreditraten zählen voll
im Monat ihrer Fälligkeit; die Kredit-Auszahlung zählt nie. Modifikatoren
wirken wie in build_cashflows (gleiche Helfer); Stichtag je Fixposten ist sein
Fälligkeitstag im Monat, je Einmalposten das Fälligkeitsdatum.
"""
import calendar
from collections import defaultdict
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 STEP, due_in_month
from app.engine.scenario import (CENT, PlainModifier, PlainPlanned,
PlainRecurring, effective_end, modified_amount)
FREI = "Frei verfügbar"
PCT = Decimal("0.1")
@dataclass(frozen=True)
class Filters:
recurring: bool = True
planned: bool = True
loans: bool = True
@dataclass(frozen=True)
class PlainLoan:
id: int
name: str
schedule: list[Installment]
@dataclass(frozen=True)
class BreakdownRow:
name: str
category: str
kind: str # "recurring" | "planned" | "loan"
rhythm: str | None
amount: Decimal # Monatsbetrag, Kosten negativ
recurring_id: int | None = None
loan_id: int | None = None
@dataclass(frozen=True)
class Summary:
income: Decimal
costs: Decimal # positiv
free: Decimal # income - costs
deficit: bool # costs > income
pie_on_income: bool # Tortenbasis = Einkünfte
category_costs: list[tuple[str, Decimal]] # positiv, absteigend
pie_rest: Decimal | None # „Frei verfügbar"-Segment
def breakdown_rows(month: date, recurring: list[PlainRecurring],
planned: list[PlainPlanned], loans: list[PlainLoan],
modifiers: list[PlainModifier], filters: Filters, *,
category_names: dict[int, str], loan_category: str,
uncategorized: str) -> list[BreakdownRow]:
first = month.replace(day=1)
last = first.replace(day=calendar.monthrange(first.year, first.month)[1])
def cat(category_id: int | None) -> str:
if category_id is None:
return uncategorized
return category_names.get(category_id, uncategorized)
out: list[BreakdownRow] = []
if filters.recurring:
for r in recurring:
if r.start_date is not None and r.start_date > last:
continue
end = effective_end(r, modifiers)
if end is not None and end < first:
continue
amount = modified_amount(r.amount, r.category_id, r.id, modifiers,
on=due_in_month(first, r.due_day))
if amount is None:
continue
monthly = (amount / STEP[r.rhythm]).quantize(CENT, ROUND_HALF_UP)
out.append(BreakdownRow(r.name, cat(r.category_id), "recurring",
r.rhythm, monthly, recurring_id=r.id))
if filters.planned:
for p in planned:
if not first <= p.due <= last:
continue
amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due)
if amount is None:
continue
out.append(BreakdownRow(p.name, cat(p.category_id), "planned", None,
amount.quantize(CENT, ROUND_HALF_UP)))
if filters.loans:
for loan in loans:
total = sum((i.payment for i in loan.schedule if first <= i.due <= last),
Decimal("0"))
out.append(BreakdownRow(loan.name, loan_category, "loan", None,
total, loan_id=loan.id))
return sorted((r for r in out if r.amount != 0), key=lambda r: (r.amount, r.name))
def share(amount: Decimal, basis: Decimal) -> Decimal | None:
"""|amount| in Prozent von basis, eine Nachkommastelle; basis 0 -> None."""
if basis == 0:
return None
return (abs(amount) * 100 / basis).quantize(PCT, ROUND_HALF_UP)
def summarize(rows: list[BreakdownRow]) -> Summary:
income = sum((r.amount for r in rows if r.amount > 0), Decimal("0"))
costs = sum((-r.amount for r in rows if r.amount < 0), Decimal("0"))
free = income - costs
per_category: dict[str, Decimal] = defaultdict(Decimal)
for r in rows:
if r.amount < 0:
per_category[r.category] += -r.amount
category_costs = sorted(per_category.items(), key=lambda kv: (-kv[1], kv[0]))
deficit = costs > income
pie_on_income = income > 0 and not deficit
pie_rest = free if pie_on_income and free > 0 else None
return Summary(income, costs, free, deficit, pie_on_income, category_costs, pie_rest)
def category_rows(rows: list[BreakdownRow], categories: set[str]) -> list[BreakdownRow]:
"""Kostenzeilen der genannten Kategorien (Einnahmen erscheinen in keiner Torte)."""
return [r for r in rows if r.amount < 0 and r.category in categories]
+8
View File
@@ -54,3 +54,11 @@ def loan_schedule(principal: Decimal, annual_rate_pct: Decimal, term_months: int
-(interest + principal_part), interest, -(interest + principal_part), interest,
principal_part, remaining.quantize(CENT))) principal_part, remaining.quantize(CENT)))
return plan return plan
def schedule_for(loan) -> list[Installment]:
"""Tilgungsplan eines Kredit-Objekts (ORM-Loan oder gleichartig mit
principal, annual_rate_pct, term_months, payout_date, repayment_type).
Ersetzt die wiederholte Feld-für-Feld-Übergabe an loan_schedule (A14)."""
return loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
loan.term_months, loan.payout_date, loan.repayment_type)
+81
View File
@@ -0,0 +1,81 @@
"""Tortendiagramm-Segmente (Ausbaustufe 13), rein.
Beträge und Anteile bleiben Decimal; nur die Geometrie (Winkel, SVG-
Koordinaten im Einheitskreis) rechnet mit float. Start 12 Uhr, im
Uhrzeigersinn (SVG-y zeigt nach unten). Große Segmente (> 50 %) werden als
zwei Teilbögen durch Mittelpunkt gezogen, um Rundungsfehler zu vermeiden.
"""
import math
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
UEBRIGE = "Übrige"
MIN_SHARE = Decimal("0.03")
MAX_NAMED = 8
PCT = Decimal("0.1")
@dataclass(frozen=True)
class Slice:
label: str
value: Decimal
share: Decimal # Prozent der Tortenbasis, eine Nachkommastelle
path: str | None # SVG-d; None = Vollkreis (einziges Segment)
color: str # "s1".."s8", "uebrige", "frei"
merged: bool
members: tuple[str, ...]
start: float # Anteil 0..1
end: float
def _point(fraction: float) -> tuple[float, float]:
angle = 2 * math.pi * fraction - math.pi / 2
return math.cos(angle), math.sin(angle)
def _arc(start: float, end: float) -> str:
x0, y0 = _point(start)
x1, y1 = _point(end)
if end - start > 0.5:
# Großes Segment: zwei Teilbögen durch Mittelpunkt, um Rundungsfehler zu vermeiden
mid = (start + end) / 2
xm, ym = _point(mid)
return f"M 0 0 L {x0:.4f} {y0:.4f} A 1 1 0 0 1 {xm:.4f} {ym:.4f} A 1 1 0 0 1 {x1:.4f} {y1:.4f} Z"
else:
return f"M 0 0 L {x0:.4f} {y0:.4f} A 1 1 0 0 1 {x1:.4f} {y1:.4f} Z"
def pie_slices(values: list[tuple[str, Decimal]], rest: Decimal | None = None,
rest_label: str = "", min_share: Decimal = MIN_SHARE,
max_named: int = MAX_NAMED) -> list[Slice]:
items = sorted(((l, v) for l, v in values if v > 0), key=lambda lv: (-lv[1], lv[0]))
rest = rest if rest is not None and rest > 0 else None
total = sum((v for _, v in items), Decimal("0")) + (rest or Decimal("0"))
if total <= 0:
return []
named: list[tuple[str, Decimal]] = []
small: list[tuple[str, Decimal]] = []
for label, value in items:
if len(named) < max_named and value / total >= min_share:
named.append((label, value))
else:
small.append((label, value))
parts = [(l, v, f"s{i}", False, ()) for i, (l, v) in enumerate(named, 1)]
if len(small) == 1:
parts.append((small[0][0], small[0][1], "uebrige", False, ()))
elif small:
parts.append((UEBRIGE, sum((v for _, v in small), Decimal("0")), "uebrige",
True, tuple(l for l, _ in small)))
if rest is not None:
parts.append((rest_label, rest, "frei", False, ()))
slices: list[Slice] = []
acc = Decimal("0")
for label, value, color, merged, members in parts:
start = float(acc / total)
acc += value
end = float(acc / total)
slices.append(Slice(label, value,
(value * 100 / total).quantize(PCT, ROUND_HALF_UP),
None if len(parts) == 1 else _arc(start, end),
color, merged, members, start, end))
return slices
+6
View File
@@ -8,6 +8,12 @@ def _clamp(y: int, m: int, day: int) -> date:
return date(y, m, min(day, calendar.monthrange(y, m)[1])) return date(y, m, min(day, calendar.monthrange(y, m)[1]))
def due_in_month(month: date, due_day: int) -> date:
"""Fälligkeitstag `due_day` im Monat von `month`, auf die Monatslänge
geklemmt (gleiche Regel wie occurrences)."""
return _clamp(month.year, month.month, due_day)
def occurrences(rhythm: str, due_day: int, window_start: date, window_end: date, 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]: item_start: date | None = None, item_end: date | None = None) -> list[date]:
step = STEP[rhythm] step = STEP[rhythm]
+37 -22
View File
@@ -10,7 +10,7 @@ CENT = Decimal("0.01")
@dataclass(frozen=True) @dataclass(frozen=True)
class PlainRecurring: class PlainRecurring:
id: int id: int | None # None = szenario-eigene Zahlung (Posten-Einträge treffen sie nie)
name: str name: str
amount: Decimal amount: Decimal
rhythm: str rhythm: str
@@ -34,19 +34,36 @@ class PlainModifier:
target_id: int target_id: int
kind: str kind: str
value: Decimal value: Decimal
end_date: date | None = None end_date: date | None = None # ende: Enddatum; sonst „Gültig bis" (inklusiv)
start_date: date | None = None # „Gültig ab" (inklusiv); bei ende immer None
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None, def _trifft(m: PlainModifier, category_id: int | None, recurring_id: int | None) -> bool:
modifiers: list[PlainModifier]) -> Decimal | None: return ((m.target_type == "category" and category_id is not None
and category_id == m.target_id)
or (m.target_type == "recurring" and recurring_id is not None
and recurring_id == m.target_id))
def gilt_am(m: PlainModifier, on: date) -> bool:
"""Liegt der Stichtag im Zeitraum des Eintrags (Grenzen inklusiv)?"""
return ((m.start_date is None or on >= m.start_date)
and (m.end_date is None or on <= m.end_date))
def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None,
modifiers: list[PlainModifier], *, on: date) -> Decimal | None:
"""Betrag einer Fälligkeit am Stichtag `on` nach allen treffenden, dort
gültigen Einträgen in Anlage-Reihenfolge. None = entfällt. Art "ende"
wirkt nur über effective_end."""
for m in modifiers: for m in modifiers:
hit = ((m.target_type == "category" and category_id == m.target_id) if m.kind == "ende" or not _trifft(m, category_id, recurring_id) or not gilt_am(m, on):
or (m.target_type == "recurring" and recurring_id == m.target_id))
if not hit:
continue continue
if m.kind == "remove": if m.kind == "remove":
return None return None
if m.kind == "percent": if m.kind == "set":
amount = m.value
elif m.kind == "percent":
amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP) amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP)
elif m.kind == "absolute": elif m.kind == "absolute":
if amount < 0: if amount < 0:
@@ -56,16 +73,13 @@ def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None
return amount return amount
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None: def effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren.""" """Frühestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
end = r.end_date end = r.end_date
for m in modifiers: for m in modifiers:
if m.kind != "ende" or m.end_date is None: if m.kind != "ende" or m.end_date is None or not _trifft(m, r.category_id, r.id):
continue continue
hit = ((m.target_type == "category" and r.category_id == m.target_id) end = m.end_date if end is None else min(end, m.end_date)
or (m.target_type == "recurring" and r.id == m.target_id))
if hit:
end = m.end_date if end is None else min(end, m.end_date)
return end return end
@@ -76,15 +90,16 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
window_start: date, window_end: date) -> list[tuple[date, Decimal]]: window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
flows: list[tuple[date, Decimal]] = [] flows: list[tuple[date, Decimal]] = []
for r in recurring: 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, for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
r.start_date, _effective_end(r, modifiers)): r.start_date, effective_end(r, modifiers)):
flows.append((d, amount)) amount = modified_amount(r.amount, r.category_id, r.id, modifiers, on=d)
if amount is not None:
flows.append((d, amount))
for p in planned: for p in planned:
amount = _modified(p.amount, p.category_id, None, modifiers) if not window_start <= p.due <= window_end:
if amount is not None and window_start <= p.due <= window_end: continue
amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due)
if amount is not None:
flows.append((p.due, amount)) flows.append((p.due, amount))
for d, payout in loan_payouts: for d, payout in loan_payouts:
if window_start <= d <= window_end: if window_start <= d <= window_end:
+10 -1
View File
@@ -13,7 +13,8 @@ DE_LABELS = {
"annuity": "Annuität", "annuity": "Annuität",
"bullet": "endfällig", "bullet": "endfällig",
"percent": "Prozent", "percent": "Prozent",
"absolute": "Absolut", "absolute": "Kürzung (€)",
"set": "Neuer Betrag",
"remove": "Entfällt", "remove": "Entfällt",
"ende": "Ende", "ende": "Ende",
} }
@@ -28,3 +29,11 @@ def eur(value) -> str:
def de_label(value: str) -> str: def de_label(value: str) -> str:
return DE_LABELS.get(value, value) return DE_LABELS.get(value, value)
def pct(value) -> str:
"""Decimal("12.5") -> "12,5 %"; None -> "–" (z. B. keine Einkünfte)."""
if value is None:
return "–"
d = Decimal(str(value)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
return f"{d}".replace(".", ",") + " %"
+22 -2
View File
@@ -123,9 +123,11 @@ class ScenarioModifier(Base):
target_id: Mapped[int] = mapped_column(Integer) target_id: Mapped[int] = mapped_column(Integer)
kind: Mapped[str] = mapped_column(String(20)) kind: Mapped[str] = mapped_column(String(20))
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0")) value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
# Nur fuer kind="ende" gesetzt: Datum, an dem der Ziel-Posten innerhalb # kind="ende": Datum, an dem der Ziel-Posten in diesem Szenario endet (A5).
# dieses Szenarios endet (Ausbaustufe 5). # Alle anderen Arten: „Gültig bis" (inklusiv, NULL = unbegrenzt, A14).
end_date: Mapped[date | None] = mapped_column(Date, nullable=True) end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# „Gültig ab" (inklusiv, NULL = unbegrenzt); bei kind="ende" immer NULL (A14).
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
class ScenarioPlannedItem(Base): class ScenarioPlannedItem(Base):
@@ -138,6 +140,24 @@ class ScenarioPlannedItem(Base):
name: Mapped[str] = mapped_column(String(200)) name: Mapped[str] = mapped_column(String(200))
amount: Mapped[Decimal] = mapped_column(MONEY) amount: Mapped[Decimal] = mapped_column(MONEY)
due: Mapped[date] = mapped_column(Date) due: Mapped[date] = mapped_column(Date)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
class ScenarioRecurringItem(Base):
"""Wiederkehrende Zahlung, die NUR in einem bestimmten Szenario zählt -
unabhängig von Scenario.include_recurring. Posten-Einträge
(target_type="recurring") treffen sie nie, Kategorie-Einträge schon.
Ausbaustufe 14."""
__tablename__ = "scenario_recurring_items"
id: Mapped[int] = mapped_column(primary_key=True)
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=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 ProjectionPoint(Base): class ProjectionPoint(Base):
+168 -4
View File
@@ -1,7 +1,9 @@
import logging import logging
import re
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Callable from typing import Callable
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -13,11 +15,14 @@ from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.engine.loans import add_months from app.engine.loans import add_months
from app.engine.recurrence import occurrences from app.engine.recurrence import occurrences
from app.formats import de_label, eur from app.engine.breakdown import FREI, BreakdownRow, Filters, category_rows, share
from app.engine.pie import UEBRIGE, pie_slices
from app.formats import de_label, eur, pct
from app.models.tables import (Account, Category, PlannedItem, from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem, ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier, Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, Transaction) ScenarioPlannedItem, ScenarioRecurringItem,
Transaction)
from app.routers.imports import list_imports as _list_imports from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import from app.routers.imports import preview as _preview_import
from app.routers.planning import list_loans as _list_loans from app.routers.planning import list_loans as _list_loans
@@ -27,6 +32,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.breakdown import PLAN_NAME, compute_breakdown
from app.services.double_count import all_conflicts 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
@@ -54,6 +60,7 @@ def grafana_public_base(request: Request) -> str:
templates.env.globals["grafana_public_base"] = grafana_public_base templates.env.globals["grafana_public_base"] = grafana_public_base
templates.env.filters["eur"] = eur templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label templates.env.filters["de_label"] = de_label
templates.env.filters["pct"] = pct
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -316,11 +323,16 @@ def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]
select(ScenarioPlannedItem) select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == sc.id) .where(ScenarioPlannedItem.scenario_id == sc.id)
.order_by(ScenarioPlannedItem.due)).scalars().all() .order_by(ScenarioPlannedItem.due)).scalars().all()
recurring_items = session.execute(
select(ScenarioRecurringItem)
.where(ScenarioRecurringItem.scenario_id == sc.id)
.order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars().all()
rows.append({ rows.append({
"scenario": sc, "scenario": sc,
"assigned_loan_ids": assigned_loan_ids, "assigned_loan_ids": assigned_loan_ids,
"modifiers": modifiers, "modifiers": modifiers,
"planned_items": planned_items, "planned_items": planned_items,
"recurring_items": recurring_items,
"result": session.get(ProjectionResult, sc.id), "result": session.get(ProjectionResult, sc.id),
}) })
return rows return rows
@@ -372,5 +384,157 @@ def szenarien_page(request: Request, session: Session = Depends(get_session)):
"recurring_names": {r.id: r.name for r in recurring}, "recurring_names": {r.id: r.name for r in recurring},
"loans": loans, "loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans), "scenario_rows": _scenario_rows(session, scenarios, loans),
"modifier_kinds": ["percent", "absolute", "remove", "ende"], "modifier_kinds": ["percent", "absolute", "set", "remove", "ende"],
"rhythms": ["monthly", "quarterly", "yearly"],
}) })
# --- Aufteilung (Ausbaustufe 13) ---
FEHLER_SZENARIO_VERGANGENHEIT = ("Szenarien sind erst ab dem laufenden Monat wählbar. "
"Für vergangene Monate „System-Plan“ wählen.")
def _parse_monat(value: str | None, today: date) -> date | None:
"""„JJJJ-MM" -> Monatserster; leer -> laufender Monat; ungültig -> None."""
if not value:
return today.replace(day=1)
m = re.fullmatch(r"(\d{4})-(\d{2})", value.strip())
if not m or not 1 <= int(m.group(2)) <= 12 or int(m.group(1)) < 1:
return None
return date(int(m.group(1)), int(m.group(2)), 1)
def _art(row: BreakdownRow) -> str:
if row.kind == "recurring":
return f"Fixposten ({de_label(row.rhythm)})"
return "Einmalposten" if row.kind == "planned" else "Kredit"
def _segmente(slices) -> list[dict]:
return [{"s": s, "q": None, "gewaehlt": False, "zusatz": None} for s in slices]
def _aufteilung_context(session: Session, today: date, monat: str | None,
szenario: str | None, fixposten: str | None,
einmal: str | None, kredite: str | None,
gesendet: str | None) -> dict:
# Erstaufruf (ohne gesendet): alle Filter an. Nach Absenden zählt nur,
# was angehakt ist - der Browser sendet leere Checkboxen gar nicht.
filters = (Filters(recurring=fixposten == "1", planned=einmal == "1",
loans=kredite == "1") if gesendet else Filters())
ctx: dict = {
"scenarios": session.execute(select(Scenario).order_by(Scenario.name)).scalars().all(),
"filters": filters, "plan_name": PLAN_NAME,
"monat_wert": monat or today.strftime("%Y-%m"), "monat_anzeige": None,
"szenario_id": None, "szenario_name": PLAN_NAME, "error": None, "b": None,
}
month = _parse_monat(monat, today)
if month is None:
ctx["error"] = f"Ungültiger Monat „{monat}“ – bitte im Format JJJJ-MM wählen."
return ctx
ctx["monat_wert"] = f"{month.year:04d}-{month.month:02d}"
ctx["monat_anzeige"] = f"{month.month:02d}.{month.year:04d}"
scenario = None
if szenario:
valid_id = re.fullmatch(r"[0-9]{1,9}", szenario) # ASCII, kein Integer-Überlauf
scenario = session.get(Scenario, int(szenario)) if valid_id else None
if scenario is None:
ctx["error"] = "Unbekanntes Szenario – bitte neu wählen."
return ctx
ctx["szenario_id"], ctx["szenario_name"] = scenario.id, scenario.name
if month < today.replace(day=1):
ctx["error"] = FEHLER_SZENARIO_VERGANGENHEIT
return ctx
b = compute_breakdown(session, month, scenario, filters)
s = b.summary
ctx.update({
"b": b, "s": s,
"zeilen": [{"r": r, "art": _art(r), "einnahme": r.amount > 0,
"anteil": share(r.amount, s.income) if r.amount < 0 else None}
for r in b.rows],
"frei_anteil": share(s.free, s.income),
"haupt": _segmente(pie_slices(s.category_costs, rest=s.pie_rest, rest_label=FREI)),
"haupt_titel": ("Anteile an den Einkünften" if s.pie_on_income
else "Anteile an den Gesamtkosten"),
})
return ctx
def _basis_query(ctx: dict) -> dict:
q = {"monat": ctx["monat_wert"], "szenario": ctx["szenario_id"] or "", "gesendet": "1"}
f = ctx["filters"]
for key, on in (("fixposten", f.recurring), ("einmal", f.planned), ("kredite", f.loans)):
if on:
q[key] = "1"
return q
def _drilldown(ctx: dict, kategorie: str | None, sammel: str | None) -> None:
"""Links der Haupttorte + ggf. zweite Torte. Die Sammelgruppe „Übrige"
läuft über sammel=1, damit eine echte Kategorie „Übrige" eindeutig bleibt."""
ctx["detail"] = None
ctx["detail_hinweis"] = None
if ctx["b"] is None:
return
sammel_gewaehlt = sammel == "1"
basis = _basis_query(ctx)
for seg in ctx["haupt"]:
s = seg["s"]
if s.color == "frei":
continue
extra = {"sammel": "1"} if s.merged else {"kategorie": s.label}
seg["q"] = urlencode({**basis, **extra})
seg["gewaehlt"] = s.merged if sammel_gewaehlt else (kategorie == s.label and not s.merged)
if sammel_gewaehlt:
merged = next((seg["s"] for seg in ctx["haupt"] if seg["s"].merged), None)
titel = UEBRIGE
enthaelt = list(merged.members) if merged else []
elif kategorie:
titel, enthaelt = kategorie, []
else:
return
rows = category_rows(ctx["b"].rows, set(enthaelt) if sammel_gewaehlt else {kategorie})
if not rows:
ctx["detail_hinweis"] = f"Kategorie „{titel}“ hat in dieser Auswahl keine Kosten."
return
income = ctx["s"].income
segmente = _segmente(pie_slices([(r.name, -r.amount) for r in rows]))
for seg in segmente:
anteil = share(seg["s"].value, income)
seg["zusatz"] = f"{pct(anteil)} der Einkünfte" if anteil is not None else None
ctx["detail"] = {"titel": titel, "enthaelt": enthaelt, "segmente": segmente,
"summe": sum((-r.amount for r in rows), Decimal("0"))}
@router.get("/aufteilung", dependencies=[Depends(gui_session)])
def aufteilung_page(request: Request, monat: str | None = None,
szenario: str | None = None, fixposten: str | None = None,
einmal: str | None = None, kredite: str | None = None,
gesendet: str | None = None, kategorie: str | None = None,
sammel: str | None = None,
session: Session = Depends(get_session)):
ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten,
einmal, kredite, gesendet)
_drilldown(ctx, kategorie, sammel)
return templates.TemplateResponse(request, "aufteilung.html", ctx)
@router.get("/aufteilung/kategorie", dependencies=[Depends(gui_session)])
def aufteilung_kategorie_fragment(request: Request, monat: str | None = None,
szenario: str | None = None,
fixposten: str | None = None,
einmal: str | None = None,
kredite: str | None = None,
gesendet: str | None = None,
kategorie: str | None = None,
sammel: str | None = None,
session: Session = Depends(get_session)):
"""htmx-Fragment: zweite Torte + Haupttorte out-of-band (Markierung)."""
ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten,
einmal, kredite, gesendet)
_drilldown(ctx, kategorie, sammel)
if ctx["error"]:
ctx["detail_hinweis"] = ctx["error"]
ctx["oob"] = ctx["b"] is not None
return templates.TemplateResponse(request, "_aufteilung_kategorie.html", ctx)
+12 -6
View File
@@ -9,7 +9,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.engine.loans import loan_schedule from app.engine.loans import schedule_for
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, from app.services.double_count import (CODE as DOPPELZAEHLUNG_CODE, Konflikt,
@@ -227,7 +227,7 @@ class LoanIn(BaseModel):
name: str name: str
principal: Decimal principal: Decimal
annual_rate_pct: Decimal annual_rate_pct: Decimal
term_months: int term_months: int = Field(ge=1, le=1200)
payout_date: date payout_date: date
repayment_type: Literal["annuity", "bullet"] = "annuity" repayment_type: Literal["annuity", "bullet"] = "annuity"
@@ -236,16 +236,23 @@ class LoanCreate(LoanIn):
doppelzaehlung_bestaetigt: bool = False doppelzaehlung_bestaetigt: bool = False
class LoanOut(LoanIn): class LoanOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str
principal: Decimal
annual_rate_pct: Decimal
# Ausgabe darf Altbestände nicht ablehnen: keine Grenzen auf term_months.
term_months: int
payout_date: date
repayment_type: Literal["annuity", "bullet"]
class LoanPatch(BaseModel): class LoanPatch(BaseModel):
name: str | None = None name: str | None = None
principal: Decimal | None = None principal: Decimal | None = None
annual_rate_pct: Decimal | None = None annual_rate_pct: Decimal | None = None
term_months: int | None = None term_months: int | None = Field(default=None, ge=1, le=1200)
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 doppelzaehlung_bestaetigt: bool = False
@@ -318,8 +325,7 @@ def get_loan_schedule(loan_id: int, session: Session = Depends(get_session)):
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")
plan = loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct), plan = schedule_for(loan)
loan.term_months, loan.payout_date, loan.repayment_type)
return [InstallmentOut(due=i.due, payment=i.payment, interest=i.interest, return [InstallmentOut(due=i.due, payment=i.payment, interest=i.interest,
principal=i.principal, remaining=i.remaining) principal=i.principal, remaining=i.remaining)
for i in plan] for i in plan]
+79 -7
View File
@@ -12,7 +12,8 @@ from app.config import get_settings
from app.db import get_session 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, ScenarioRecurringItem)
from app.routers.planning import RecurringIn, _check_category
from app.reserved import check_reserved_name from app.reserved import check_reserved_name
from app.services.projection_service import run_projection from app.services.projection_service import run_projection
@@ -47,16 +48,23 @@ class ScenarioPatch(BaseModel):
class ModifierIn(BaseModel): class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"] target_type: Literal["category", "recurring"]
target_id: int target_id: int
kind: Literal["percent", "absolute", "remove", "ende"] kind: Literal["percent", "absolute", "set", "remove", "ende"]
value: Decimal = Decimal("0") value: Decimal = Decimal("0")
start_date: date | None = None
end_date: date | None = None end_date: date | None = None
@model_validator(mode="after") @model_validator(mode="after")
def _ende_braucht_datum(self): def _art_und_zeitraum(self):
if self.kind == "ende" and self.end_date is None: # end_date = Enddatum bei "ende", sonst „Gültig bis" (A14).
raise ValueError("Art »Ende« erfordert ein Datum") if self.kind == "ende":
if self.kind != "ende": if self.end_date is None:
self.end_date = None raise ValueError("Art »Ende« erfordert ein Datum")
self.start_date = None
if self.kind == "set" and self.target_type != "recurring":
raise ValueError("Art »Neuer Betrag« ist nur für Posten möglich")
if (self.start_date is not None and self.end_date is not None
and self.start_date > self.end_date):
raise ValueError("»Gültig ab« liegt nach »Gültig bis«")
return self return self
@@ -127,6 +135,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id) scenario = _get_scenario(session, scenario_id)
# No DB-level cascades exist, so remove dependent rows explicitly to avoid # No DB-level cascades exist, so remove dependent rows explicitly to avoid
# foreign-key violations (would surface as a 500 on Postgres). # foreign-key violations (would surface as a 500 on Postgres).
session.execute(delete(ScenarioRecurringItem).where(
ScenarioRecurringItem.scenario_id == scenario_id))
session.execute(delete(ProjectionPoint).where( session.execute(delete(ProjectionPoint).where(
ProjectionPoint.scenario_id == scenario_id)) ProjectionPoint.scenario_id == scenario_id))
session.execute(delete(ProjectionResult).where( session.execute(delete(ProjectionResult).where(
@@ -203,6 +213,7 @@ class ScenarioPlannedIn(BaseModel):
name: str name: str
amount: Decimal amount: Decimal
due: date due: date
category_id: int | None = None
class ScenarioPlannedOut(ScenarioPlannedIn): class ScenarioPlannedOut(ScenarioPlannedIn):
@@ -225,6 +236,7 @@ def list_scenario_planned(scenario_id: int, session: Session = Depends(get_sessi
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn, def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
session: Session = Depends(get_session)): session: Session = Depends(get_session)):
_get_scenario(session, scenario_id) _get_scenario(session, scenario_id)
_check_category(session, data.category_id)
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump()) item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
session.add(item) session.add(item)
session.commit() session.commit()
@@ -248,6 +260,7 @@ def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlanned
session: Session = Depends(get_session)): session: Session = Depends(get_session)):
# Full-Body-Update, Muster patch_modifier. # Full-Body-Update, Muster patch_modifier.
_get_scenario(session, scenario_id) _get_scenario(session, scenario_id)
_check_category(session, data.category_id)
item = session.get(ScenarioPlannedItem, item_id) item = session.get(ScenarioPlannedItem, item_id)
if item is None or item.scenario_id != scenario_id: if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Einmalzahlung nicht gefunden") raise HTTPException(404, "Einmalzahlung nicht gefunden")
@@ -278,3 +291,62 @@ def project_scenario(scenario_id: int, horizon_days: int | None = None,
below_threshold_date=result.below_threshold_date, below_threshold_date=result.below_threshold_date,
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points], series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
) )
class ScenarioRecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True)
id: int
scenario_id: int
def _get_scenario_recurring(session: Session, scenario_id: int,
item_id: int) -> ScenarioRecurringItem:
item = session.get(ScenarioRecurringItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Wiederkehrende Zahlung nicht gefunden")
return item
@router.get("/{scenario_id}/recurring", response_model=list[ScenarioRecurringOut])
def list_scenario_recurring(scenario_id: int, session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
return [ScenarioRecurringOut.model_validate(r) for r in session.execute(
select(ScenarioRecurringItem)
.where(ScenarioRecurringItem.scenario_id == scenario_id)
.order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars()]
@router.post("/{scenario_id}/recurring", response_model=ScenarioRecurringOut,
status_code=201)
def add_scenario_recurring(scenario_id: int, data: RecurringIn,
session: Session = Depends(get_session)):
# Bewusst ohne Doppelzählungs-Prüfung (gilt nur für globale Fixposten).
_get_scenario(session, scenario_id)
_check_category(session, data.category_id)
item = ScenarioRecurringItem(scenario_id=scenario_id, **data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return ScenarioRecurringOut.model_validate(item)
@router.patch("/{scenario_id}/recurring/{item_id}", response_model=ScenarioRecurringOut)
def patch_scenario_recurring(scenario_id: int, item_id: int, data: RecurringIn,
session: Session = Depends(get_session)):
# Full-Body-Update, Muster patch_scenario_planned.
_get_scenario(session, scenario_id)
item = _get_scenario_recurring(session, scenario_id, item_id)
_check_category(session, data.category_id)
for key, value in data.model_dump().items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return ScenarioRecurringOut.model_validate(item)
@router.delete("/{scenario_id}/recurring/{item_id}", status_code=204)
def delete_scenario_recurring(scenario_id: int, item_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
session.delete(_get_scenario_recurring(session, scenario_id, item_id))
session.commit()
+45
View File
@@ -0,0 +1,45 @@
"""Aufteilung (Ausbaustufe 13): Brücke DB -> engine/breakdown.py.
scenario=None ist „System-Plan" (reine Planung, alle Kredite, keine
Modifikatoren) - dieselbe Datenbasis wie load_scenario_inputs(None).
"""
from dataclasses import dataclass
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.engine.breakdown import (BreakdownRow, Filters, PlainLoan, Summary,
breakdown_rows, summarize)
from app.engine.loans import schedule_for
from app.models.tables import Category, Scenario
from app.reserved import SYSTEM_PREFIX
from app.services.double_count import Konflikt, all_conflicts
from app.services.planning_chart import KREDITRATEN, UNKATEGORISIERT
from app.services.projection_service import load_scenario_inputs
PLAN_NAME = f"{SYSTEM_PREFIX}Plan"
@dataclass(frozen=True)
class Breakdown:
month: date
rows: list[BreakdownRow]
summary: Summary
conflicts: list[Konflikt]
def compute_breakdown(session: Session, month: date, scenario: Scenario | None,
filters: Filters) -> Breakdown:
inputs = load_scenario_inputs(session, scenario)
names = {c.id: c.name for c in session.execute(select(Category)).scalars()}
loans = [PlainLoan(l.id, l.name, schedule_for(l)) for l in inputs.loans]
rows = breakdown_rows(month, inputs.recurring, inputs.planned, loans,
inputs.modifiers, filters, category_names=names,
loan_category=KREDITRATEN, uncategorized=UNKATEGORISIERT)
# Doppelzählungs-Hinweis nur, wenn Fixposten UND Kredit hier als Zeile stehen.
posten = {r.recurring_id for r in rows if r.recurring_id is not None}
kredite = {r.loan_id for r in rows if r.loan_id is not None}
conflicts = [k for k in all_conflicts(session)
if k.posten_id in posten and k.kredit_id in kredite]
return Breakdown(month.replace(day=1), rows, summarize(rows), conflicts)
+2 -4
View File
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.db import advisory_xact_lock from app.db import advisory_xact_lock
from app.engine.loans import loan_schedule from app.engine.loans import schedule_for
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)
@@ -46,9 +46,7 @@ def planning_monthly_totals(session: Session, today: date,
if window_start <= p.due <= window_end: if window_start <= p.due <= window_end:
totals[(p.due.replace(day=1), category(p.category_id))] += Decimal(p.amount) totals[(p.due.replace(day=1), category(p.category_id))] += Decimal(p.amount)
for loan in session.execute(select(Loan)).scalars(): for loan in session.execute(select(Loan)).scalars():
for inst in loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct), for inst in schedule_for(loan):
loan.term_months, loan.payout_date,
loan.repayment_type):
if window_start <= inst.due <= window_end: if window_start <= inst.due <= window_end:
# payment ist bereits negativ (Abfluss), wie in build_cashflows. # payment ist bereits negativ (Abfluss), wie in build_cashflows.
totals[(inst.due.replace(day=1), KREDITRATEN)] += inst.payment totals[(inst.due.replace(day=1), KREDITRATEN)] += inst.payment
+61 -29
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import date, timedelta from datetime import date, timedelta
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
@@ -7,47 +8,78 @@ from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.db import advisory_xact_lock from app.db import advisory_xact_lock
from app.engine.loans import loan_schedule from app.engine.loans import schedule_for
from app.engine.projection import project from app.engine.projection import project
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring, from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
build_cashflows) build_cashflows)
from app.models.tables import (Loan, PlannedItem, ProjectionPoint, from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
ProjectionResult, RecurringItem, Scenario, ProjectionResult, RecurringItem, Scenario,
ScenarioLoan, ScenarioModifier, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem) ScenarioPlannedItem, ScenarioRecurringItem)
from app.services.balances import total_balance from app.services.balances import total_balance
@dataclass(frozen=True)
class ScenarioInputs:
"""Eingaben einer Szenario-Rechnung (Projektion wie Aufteilung)."""
recurring: list[PlainRecurring]
planned: list[PlainPlanned]
loans: list[Loan]
modifiers: list[PlainModifier]
def load_scenario_inputs(session: Session, scenario: Scenario | None) -> ScenarioInputs:
"""Lädt die Eingaben eines Szenarios. scenario=None ist die reine
Planung („System-Plan"): alle Fixposten, alle globalen Einmalposten,
alle Kredite, keine Modifikatoren, keine Szenario-Einmalposten und keine
szenario-eigenen wiederkehrenden Zahlungen."""
recurring = []
if scenario is None or 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).order_by(RecurringItem.id)).scalars()]
planned = []
if scenario is None or scenario.include_planned:
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
for p in session.execute(
select(PlannedItem).order_by(PlannedItem.id)).scalars()]
if scenario is None:
loans = list(session.execute(select(Loan).order_by(Loan.id)).scalars())
return ScenarioInputs(recurring, planned, loans, [])
# Szenario-eigene Zahlungen zählen IMMER für dieses Szenario -
# include_recurring/include_planned steuern nur die globalen Posten.
# id=None: Posten-Einträge (target_type="recurring") treffen sie nie,
# die IDs zweier Tabellen könnten sonst kollidieren.
recurring += [PlainRecurring(None, 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(ScenarioRecurringItem).where(
ScenarioRecurringItem.scenario_id == scenario.id)
.order_by(ScenarioRecurringItem.id)).scalars()]
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
for p in session.execute(select(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario.id)
.order_by(ScenarioPlannedItem.id)).scalars()]
loans = list(session.execute(
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
.where(ScenarioLoan.scenario_id == scenario.id)
.order_by(Loan.id)).scalars())
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
Decimal(m.value), m.end_date, m.start_date)
for m in session.execute(select(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario.id)
.order_by(ScenarioModifier.id)).scalars()]
return ScenarioInputs(recurring, planned, loans, modifiers)
def run_projection(session: Session, scenario: Scenario, horizon_days: int, def run_projection(session: Session, scenario: Scenario, horizon_days: int,
start_date: date) -> ProjectionResult: start_date: date) -> ProjectionResult:
end = start_date + timedelta(days=horizon_days) end = start_date + timedelta(days=horizon_days)
recurring = [] inputs = load_scenario_inputs(session, scenario)
if scenario.include_recurring: schedules = [schedule_for(l) for l in inputs.loans]
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm, payouts = [(l.payout_date, Decimal(l.principal)) for l in inputs.loans]
r.due_day, r.start_date, r.end_date, r.category_id) flows = build_cashflows(inputs.recurring, inputs.planned, schedules, payouts,
for r in session.execute(select(RecurringItem)).scalars()] inputs.modifiers, start_date, end)
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()]
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
# include_planned steuert nur die globalen PlannedItems.
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
for p in session.execute(select(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario.id)).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), m.end_date)
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, proj = project(total_balance(session), start_date, flows, horizon_days,
threshold=get_settings().warn_threshold) threshold=get_settings().warn_threshold)
session.execute(delete(ProjectionPoint).where( session.execute(delete(ProjectionPoint).where(
+52
View File
@@ -12,6 +12,8 @@ body {
nav { nav {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
row-gap: 0.4rem;
align-items: center; align-items: center;
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -37,6 +39,12 @@ table {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
/* Schmale Bildschirme: breite Tabellen scrollen in sich, nicht die Seite (A14). */
@media (max-width: 600px) {
table { display: block; overflow-x: auto; }
fieldset { min-width: 0; }
}
th, td { th, td {
border: 1px solid #ddd; border: 1px solid #ddd;
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
@@ -236,3 +244,47 @@ label:has(input:disabled), label:has(select:disabled) {
#doppelzaehlung-dialog { #doppelzaehlung-dialog {
max-width: 32rem; max-width: 32rem;
} }
/* Aufteilung (Ausbaustufe 13). Tortenfarben: dataviz-Referenzpalette,
Slots 1-8 (Nachbarpaare inkl. Kreisschluss und Grautöne CVD-validiert auf
weißem Grund); Übrige/Frei neutral. Max. 8 benannte + Übrige + Frei. */
.aufteilung {
--seg-1: #2a78d6; --seg-2: #eb6834; --seg-3: #1baf7a; --seg-4: #eda100;
--seg-5: #e87ba4; --seg-6: #008300; --seg-7: #4a3aa7; --seg-8: #e34948;
--seg-uebrige: #8a8984; --seg-frei: #d6d5cf;
}
.kennzahlen { display: flex; flex-wrap: wrap; gap: 1.5rem; margin-bottom: 1rem; }
.kennzahlen div { display: flex; flex-direction: column; }
.kennzahlen strong { font-size: 1.25rem; }
.tabelle-scroll { overflow-x: auto; }
/* Torte: schmal untereinander, breit SVG neben Legende */
.torte { margin: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; align-items: start; }
@media (min-width: 700px) {
.torte { grid-template-columns: 280px minmax(0, 1fr); }
.torte figcaption { grid-column: 1 / -1; }
}
.torte figcaption { font-weight: bold; margin-bottom: 0.5rem; }
.torte-svg { width: 100%; max-width: 280px; display: block; margin: 0 auto; }
.torte-svg .seg { stroke: #fff; stroke-width: 0.02; }
.seg.s1 { fill: var(--seg-1); } .swatch.s1 { background: var(--seg-1); }
.seg.s2 { fill: var(--seg-2); } .swatch.s2 { background: var(--seg-2); }
.seg.s3 { fill: var(--seg-3); } .swatch.s3 { background: var(--seg-3); }
.seg.s4 { fill: var(--seg-4); } .swatch.s4 { background: var(--seg-4); }
.seg.s5 { fill: var(--seg-5); } .swatch.s5 { background: var(--seg-5); }
.seg.s6 { fill: var(--seg-6); } .swatch.s6 { background: var(--seg-6); }
.seg.s7 { fill: var(--seg-7); } .swatch.s7 { background: var(--seg-7); }
.seg.s8 { fill: var(--seg-8); } .swatch.s8 { background: var(--seg-8); }
.seg.uebrige { fill: var(--seg-uebrige); } .swatch.uebrige { background: var(--seg-uebrige); }
.seg.frei { fill: var(--seg-frei); } .swatch.frei { background: var(--seg-frei); box-shadow: inset 0 0 0 1px #aaa; }
.legende { list-style: none; padding: 0; margin: 0.75rem 0 0; font-size: 0.9rem; }
.legende li { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
.swatch { display: inline-block; width: 0.8rem; height: 0.8rem; border-radius: 2px; flex: none; }
.legende-wert { margin-left: auto; color: #555; white-space: nowrap; }
.hinweis-klein { font-size: 0.85rem; color: #555; }
.torte-svg .seg.klickbar { cursor: pointer; }
.torte-svg .seg.klickbar:hover { opacity: 0.85; }
.torte-svg .seg.gewaehlt { stroke: #222; stroke-width: 0.05; }
.legende li.gewaehlt { font-weight: bold; }
.legende-zusatz { font-weight: normal; color: #666; }
#aufteilung-kategorie h2 { font-size: 1.1rem; margin-top: 1.5rem; }
+81
View File
@@ -0,0 +1,81 @@
/* Szenarien-Seite (Ausbaustufe 14): Umschaltlogik der Formulare
* „Neue Änderung an Bestehendem" (.aenderung-form, auch in den Edit-Zeilen
* der Einträge) und „Neue Zahlung nur in diesem Szenario" (.zahlung-form).
* UX-Regel: Felder werden nur per disabled gesperrt, nie entfernt;
* gesperrte Felder tragen den Tooltip TIP. Gesperrte Felder sendet htmx
* nicht mit.
*/
(function () {
'use strict';
var TIP = 'Für diese Eintragsart nicht relevant';
var WERT_LABEL = { percent: 'Prozentsatz (%)', absolute: 'Kürzung (€)', set: 'Neuer Betrag (€)' };
function sperren(el, gesperrt) {
if (!el) { return; }
el.disabled = gesperrt;
if (gesperrt) { el.title = TIP; } else { el.removeAttribute('title'); }
}
function feld(form, name) { return form.querySelector('[name="' + name + '"]'); }
function beschriften(form, cls, text) {
var el = form.querySelector('.' + cls);
if (el) { el.textContent = text; }
}
// Ziel-Typ: passende Auswahl (Kategorie oder Posten) zeigen und freigeben.
function zielTyp(select) {
var form = select.closest('form');
var istKategorie = select.value === 'category';
var kat = form.querySelector('.mod-target-category');
var rec = form.querySelector('.mod-target-recurring');
kat.style.display = istKategorie ? '' : 'none';
rec.style.display = istKategorie ? 'none' : '';
sperren(kat.querySelector('select'), !istKategorie);
sperren(rec.querySelector('select'), istKategorie);
}
function aenderungArt(select) {
var form = select.closest('form');
var art = select.value;
var mitWert = Object.prototype.hasOwnProperty.call(WERT_LABEL, art);
var wert = feld(form, 'value');
sperren(wert, !mitWert);
if (art === 'set') {
// Vorzeichenfalle: Ausgaben sind negativ (wie Fixposten-Beträge).
wert.placeholder = '-250,00';
wert.title = 'Neuer Betrag mit Vorzeichen (Ausgabe negativ)';
if (wert.value === '0') { wert.value = ''; }
} else {
wert.removeAttribute('placeholder');
}
beschriften(form, 'value-label', mitWert ? WERT_LABEL[art] : 'Wert');
sperren(feld(form, 'start_date'), art === 'ende');
beschriften(form, 'bis-label', art === 'ende' ? 'Endet am' : 'Gültig bis');
var typ = feld(form, 'target_type');
var kategorieOption = typ.querySelector('option[value="category"]');
// „Neuer Betrag" gibt es nur für Posten.
if (art === 'set') { typ.value = 'recurring'; }
kategorieOption.disabled = art === 'set';
zielTyp(typ);
}
function zahlungArt(select) {
var form = select.closest('form');
var einmal = select.value === 'einmal';
sperren(feld(form, 'due'), !einmal);
['rhythm', 'due_day', 'start_date', 'end_date'].forEach(function (name) {
sperren(feld(form, name), einmal);
});
form.setAttribute('hx-post', form.getAttribute(einmal ? 'data-planned-url' : 'data-recurring-url'));
if (window.htmx) { window.htmx.process(form); }
}
function init(root) {
root.querySelectorAll('.aenderung-form select[name="kind"]').forEach(aenderungArt);
root.querySelectorAll('.zahlung-form select.zahlung-art').forEach(zahlungArt);
}
window.FBSzenario = { zielTyp: zielTyp, aenderungArt: aenderungArt, zahlungArt: zahlungArt, init: init };
})();
@@ -0,0 +1,9 @@
{% from "_torte_macros.html" import torte %}
{% if detail_hinweis %}
<div class="warning">{{ detail_hinweis }}</div>
{% elif detail %}
<h2>{{ detail.titel }}: {{ detail.summe|eur }} € / Monat</h2>
{% if detail.enthaelt %}<p class="hinweis-klein">Enthält: {{ detail.enthaelt|join(', ') }}</p>{% endif %}
{{ torte(detail.segmente, 'Posten: ' ~ detail.titel) }}
{% endif %}
{% if oob %}{% include "_aufteilung_torte.html" %}{% endif %}
@@ -0,0 +1,4 @@
{% from "_torte_macros.html" import torte %}
<div id="aufteilung-torte"{% if oob %} hx-swap-oob="true"{% endif %}>
{{ torte(haupt, haupt_titel) }}
</div>
+33
View File
@@ -0,0 +1,33 @@
{# Torte + Legende (Ausbaustufe 13). segmente: Liste von Dicts
{s: engine.pie.Slice, q: Query-String für den Drill-down oder None,
gewaehlt: bool, zusatz: Zusatztext oder None}.
Klickbar sind SVG-Segmente (hx-get direkt am <path>, kein SVG-<a>, weil
htmx nur HTML-Links am Navigieren hindert) und die Legenden-Links (auch
ohne JS und per Tastatur nutzbar). #}
{% macro seg_attrs(seg) -%}
class="seg {{ seg.s.color }}{% if seg.q %} klickbar{% endif %}{% if seg.gewaehlt %} gewaehlt{% endif %}"
{%- if seg.q %} hx-get="/aufteilung/kategorie?{{ seg.q }}" hx-target="#aufteilung-kategorie" hx-push-url="/aufteilung?{{ seg.q }}"{% endif %}
{%- endmacro %}
{% macro torte(segmente, titel) %}
<figure class="torte">
<figcaption>{{ titel }}</figcaption>
<svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}">
{# Gewähltes Segment zuletzt zeichnen, damit sein Umriss nicht von
Nachbarsegmenten überdeckt wird (A14). #}
{% for seg in (segmente|rejectattr('gewaehlt')|list) + (segmente|selectattr('gewaehlt')|list) %}{% set s = seg.s %}
{% if s.path %}<path d="{{ s.path }}" {{ seg_attrs(seg) }}><title>{{ s.label }}{% if s.merged %} ({{ s.members|join(', ') }}){% endif %}: {{ s.value|eur }} € ({{ s.share|pct }})</title></path>
{% else %}<circle r="1" {{ seg_attrs(seg) }}><title>{{ s.label }}{% if s.merged %} ({{ s.members|join(', ') }}){% endif %}: {{ s.value|eur }} € ({{ s.share|pct }})</title></circle>{% endif %}
{% endfor %}
</svg>
<ul class="legende">
{% for seg in segmente %}{% set s = seg.s %}
<li{% if seg.gewaehlt %} class="gewaehlt"{% endif %}><span class="swatch {{ s.color }}"></span>
{%- if seg.q %}<a href="/aufteilung?{{ seg.q }}" hx-get="/aufteilung/kategorie?{{ seg.q }}" hx-target="#aufteilung-kategorie" hx-push-url="/aufteilung?{{ seg.q }}">{{ s.label }}</a>
{%- else %}<span>{{ s.label }}</span>{% endif %}
{%- if s.merged %} <span class="legende-zusatz">({{ s.members|join(', ') }})</span>{% endif %}
<span class="legende-wert">{{ s.value|eur }} € · {{ s.share|pct }}{% if seg.zusatz %} · {{ seg.zusatz }}{% endif %}</span></li>
{% endfor %}
</ul>
</figure>
{% endmacro %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Aufteilung – Finanzberatung{% endblock %}
{% block content %}
<div class="aufteilung">
<h1>Aufteilung</h1>
<form method="get" action="/aufteilung" class="filter-form">
<input type="hidden" name="gesendet" value="1">
<label>Monat <input type="month" name="monat" value="{{ monat_wert }}" required></label>
<label>Szenario
<select name="szenario">
<option value="">{{ plan_name }}</option>
{% for sc in scenarios %}<option value="{{ sc.id }}"{% if sc.id == szenario_id %} selected{% endif %}>{{ sc.name }}</option>{% endfor %}
</select>
</label>
<label><span><input type="checkbox" name="fixposten" value="1"{% if filters.recurring %} checked{% endif %}> Fixposten</span></label>
<label><span><input type="checkbox" name="einmal" value="1"{% if filters.planned %} checked{% endif %}> Einmalposten</span></label>
<label><span><input type="checkbox" name="kredite" value="1"{% if filters.loans %} checked{% endif %}> Kredite</span></label>
<button type="submit">Anzeigen</button>
</form>
{% if monat_anzeige %}<p class="hinweis-klein">{{ monat_anzeige }} · {{ szenario_name }}</p>{% endif %}
{% if error %}
<div class="warning" role="alert">{{ error }}</div>
{% elif b %}
<div class="kennzahlen">
<div><span>Einkünfte</span><strong>{{ s.income|eur }} €</strong></div>
<div><span>Kosten</span><strong>{{ s.costs|eur }} €</strong></div>
<div{% if s.deficit %} class="neg"{% endif %}><span>{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</span><strong>{{ s.free|eur }} €</strong></div>
</div>
{% for k in b.conflicts %}
<div class="warning">⚠ Fixposten „{{ k.posten_name }}“ zählt evtl. doppelt mit Kredit „{{ k.kredit_name }}“.</div>
{% endfor %}
{% if not b.rows %}
<p>Für diese Auswahl gibt es keine Posten.</p>
{% else %}
{% if s.deficit %}
<div class="warning">Kosten übersteigen die Einkünfte um {{ (s.costs - s.income)|eur }} € — das Diagramm zeigt die Aufteilung der Gesamtkosten.</div>
{% endif %}
<div class="tabelle-scroll">
<table>
<thead>
<tr><th>Posten</th><th>Kategorie</th><th>Art</th><th>Monatsbetrag</th><th>% der Einkünfte</th></tr>
</thead>
<tbody>
{% for z in zeilen %}
<tr>
<td data-sort="{{ z.r.name }}">{{ z.r.name }}</td>
<td>{{ z.r.category }}</td>
<td>{{ z.art }}</td>
<td class="amount{% if z.r.amount < 0 %} neg{% endif %}" data-sort="{{ z.r.amount }}">{{ z.r.amount|eur }} €</td>
<td class="amount" data-sort="{{ z.anteil if z.anteil is not none else '' }}">{% if not z.einnahme %}{{ z.anteil|pct }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr{% if s.deficit %} class="neg"{% endif %}>
<th colspan="3">{{ 'Unterdeckung' if s.deficit else 'Frei verfügbar' }}</th>
<td class="amount">{{ s.free|eur }} €</td>
<td class="amount">{{ frei_anteil|pct }}</td>
</tr>
</tfoot>
</table>
</div>
<p>Gesamte Einkünfte: <strong>{{ s.income|eur }} €</strong></p>
<div>
{% include "_aufteilung_torte.html" %}
<p class="hinweis-klein">Für Kategorie-Aufteilung auf Segment klicken</p>
<div id="aufteilung-kategorie">{% include "_aufteilung_kategorie.html" %}</div>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
+7 -4
View File
@@ -4,10 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Finanzberatung{% endblock %}</title> <title>{% block title %}Finanzberatung{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css?v={{ app_version }}">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js?v={{ app_version }}"></script>
<script src="/static/sort.js"></script> <script src="/static/sort.js?v={{ app_version }}"></script>
<script src="/static/doppelzaehlung.js"></script> <script src="/static/doppelzaehlung.js?v={{ app_version }}"></script>
<script src="/static/szenarien.js?v={{ app_version }}"></script>
</head> </head>
<body> <body>
<nav> <nav>
@@ -17,6 +18,7 @@
<a href="/salden">Salden</a> <a href="/salden">Salden</a>
<a href="/planung">Planung</a> <a href="/planung">Planung</a>
<a href="/szenarien">Szenarien</a> <a href="/szenarien">Szenarien</a>
<a href="/aufteilung">Aufteilung</a>
<a href="/admin">Admin</a> <a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a> <a href="/hilfe">Hilfe</a>
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a> <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
@@ -99,6 +101,7 @@
} }
if (window.FBDoppel) { window.FBDoppel.init(document); } if (window.FBDoppel) { window.FBDoppel.init(document); }
if (window.FBSzenario) { window.FBSzenario.init(document); }
</script> </script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer> <footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body> </body>
+36 -8
View File
@@ -67,16 +67,26 @@
<li> <li>
<strong>Szenarien:</strong> eine "Was-wäre-wenn"-Rechnung. Basis = alle <strong>Szenarien:</strong> eine "Was-wäre-wenn"-Rechnung. Basis = alle
wiederkehrenden Posten + Einmalposten. Varianten entstehen durch Zuordnen wiederkehrenden Posten + Einmalposten. Varianten entstehen durch Zuordnen
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut von Krediten und Einträgen (Kategorie oder Posten prozentual oder um einen Betrag
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand kürzen, auf einen neuen Betrag setzen oder ganz streichen – auch nur für
einen Zeitraum). "Durchrechnen" liefert: tiefster Kontostand
mit Datum, erstes Datum unter 0 und unter der Warnschwelle. mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine Szenarios. Das Formular „Neue Änderung an Bestehendem“ ändert
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu vorhandene Posten oder Kategorien: „Prozent“ und „Kürzung (€)“ kürzen
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz Beträge, „Neuer Betrag“ ersetzt den Betrag eines Postens (mit
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur Vorzeichen, z. B. -250,00), „Entfällt“ blendet ihn aus, „Ende“ lässt
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt einen wiederkehrenden Posten zu einem Datum enden. Mit „Gültig ab“ und
änderbar. Ein Szenario lässt sich über „Löschen“ mit allen Einträgen „Gültig bis“ wirken Prozent, Kürzung, Neuer Betrag und Entfällt nur in
diesem Zeitraum (Grenzen einschließlich, leer = unbegrenzt). Beispiel:
ein Posten wird mit „Prozent 50 ab 16.10.“ halbiert – ist er am 14.
fällig, zählt er im Oktober noch voll, ab November zur Hälfte. Das
Formular „Neue Zahlung nur in diesem Szenario“ ergänzt eine
Einmalzahlung oder eine wiederkehrende Zahlung (Rhythmus,
Fälligkeitstag, Start/Ende, optional mit Kategorie), die nur in diesem
Szenario zählt – auch wenn „Wiederkehrende Posten einschließen“ bzw.
„Einmalposten einschließen“ aus ist. Jeder Eintrag ist über
„Bearbeiten“ direkt änderbar. Ein Szenario lässt sich über „Löschen“ mit allen Einträgen
wieder entfernen. Bereits durchgerechnete Szenarien werden automatisch wieder entfernen. Bereits durchgerechnete Szenarien werden automatisch
mit dem Standard-Horizont neu gerechnet, sobald ihre Projektion nicht mit dem Standard-Horizont neu gerechnet, sobald ihre Projektion nicht
mehr bei heute beginnt (z. B. nach ein paar Tagen Inaktivität) – das mehr bei heute beginnt (z. B. nach ein paar Tagen Inaktivität) – das
@@ -91,6 +101,24 @@
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>
<li>
<strong>Aufteilung:</strong> zeigt für einen Monat, welche monatlichen
Kosten je Kategorie anfallen und wie viel der Einkünfte frei verfügbar
bleibt. Grundlage ist immer die Planung (Fixposten, Einmalposten,
Kredite), nie die tatsächlichen Buchungen: „System-Plan“ rechnet mit
allen Posten und Krediten ohne Szenario-Änderungen und ist für jeden
Monat wählbar; ein Szenario (mit seinen Änderungen und Krediten) erst ab
dem laufenden Monat. Fixposten werden auf den Monat umgerechnet
(vierteljährlich ÷ 3, jährlich ÷ 12), Einmalposten und Kreditraten zählen
voll im Monat ihrer Fälligkeit, eine Kredit-Auszahlung zählt nicht als
Einkunft. Die Häkchen „Fixposten“, „Einmalposten“, „Kredite“ gelten für
Einnahmen und Ausgaben. Das Tortendiagramm zeigt die Einkünfte (Kosten je
Kategorie plus „Frei verfügbar“); übersteigen die Kosten die Einkünfte,
zeigt es die Gesamtkosten. Kleine Kategorien (unter 3 %, bzw. ab der
neunten) sind zu „Übrige“ zusammengefasst – höchstens 8 benannte
Kategorien plus „Übrige“ und „Frei verfügbar“. Ein Klick auf ein Segment oder
einen Legendeneintrag zeigt darunter die Posten dieser Kategorie.
</li>
</ul> </ul>
<p> <p>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden – Finanzberatung</title> <title>Anmelden – Finanzberatung</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css?v={{ app_version }}">
</head> </head>
<body> <body>
<h1>Finanzberatung</h1> <h1>Finanzberatung</h1>
+2 -2
View File
@@ -221,7 +221,7 @@
<label>Name <input type="text" name="name" value="{{ l.name }}" required></label> <label>Name <input type="text" name="name" value="{{ l.name }}" required></label>
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" value="{{ l.principal|eur }}" required></label> <label>Darlehenssumme <input type="text" name="principal" data-type="amount" value="{{ l.principal|eur }}" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" value="{{ l.annual_rate_pct|eur }}" required></label> <label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" value="{{ l.annual_rate_pct|eur }}" required></label>
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" value="{{ l.term_months }}" required></label> <label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" max="1200" value="{{ l.term_months }}" required></label>
<label>Auszahlungsdatum <input type="date" name="payout_date" value="{{ l.payout_date.isoformat() }}" required></label> <label>Auszahlungsdatum <input type="date" name="payout_date" value="{{ l.payout_date.isoformat() }}" required></label>
<label>Tilgungsart <label>Tilgungsart
<select name="repayment_type" data-type="str"> <select name="repayment_type" data-type="str">
@@ -254,7 +254,7 @@
<label>Name <input type="text" name="name" required></label> <label>Name <input type="text" name="name" required></label>
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" placeholder="10000,00" required></label> <label>Darlehenssumme <input type="text" name="principal" data-type="amount" placeholder="10000,00" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" placeholder="4,5" required></label> <label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" placeholder="4,5" required></label>
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" required></label> <label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" max="1200" required></label>
<label>Auszahlungsdatum <input type="date" name="payout_date" required></label> <label>Auszahlungsdatum <input type="date" name="payout_date" required></label>
<label>Tilgungsart <label>Tilgungsart
<select name="repayment_type" data-type="str"> <select name="repayment_type" data-type="str">
+112 -96
View File
@@ -1,6 +1,14 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Szenarien – Finanzberatung{% endblock %} {% block title %}Szenarien – Finanzberatung{% endblock %}
{% block content %} {% block content %}
{% macro zeitraum(von, bis) -%}
{%- if von and bis -%}{{ von.strftime('%d.%m.%Y') }} – {{ bis.strftime('%d.%m.%Y') }}
{%- elif von -%}ab {{ von.strftime('%d.%m.%Y') }}
{%- elif bis -%}bis {{ bis.strftime('%d.%m.%Y') }}
{%- else -%}–{%- endif -%}
{%- endmacro %}
{% set TIP = 'Für diese Eintragsart nicht relevant' %}
<h1>Szenarien</h1> <h1>Szenarien</h1>
<section class="planning-section"> <section class="planning-section">
@@ -45,7 +53,7 @@
<h3>Einträge</h3> <h3>Einträge</h3>
<table> <table>
<thead> <thead>
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr> <tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Zeitraum</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{% for m in row.modifiers %} {% for m in row.modifiers %}
@@ -55,8 +63,8 @@
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %} {% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
</td> </td>
<td>{{ m.kind|de_label }}</td> <td>{{ m.kind|de_label }}</td>
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}–{% endif %}</td> <td>{% if m.kind in ('percent', 'absolute', 'set') %}{{ m.value|eur }}{% else %}–{% endif %}</td>
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}–{% endif %}</td> <td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}{{ zeitraum(m.start_date, m.end_date) }}{% endif %}</td>
<td> <td>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button> <button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none" <form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
@@ -67,10 +75,15 @@
</tr> </tr>
<tr id="mod-edit-{{ m.id }}" hidden data-sort-follow> <tr id="mod-edit-{{ m.id }}" hidden data-sort-follow>
<td colspan="5"> <td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none" <form class="aenderung-form" hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Art
<select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
</select>
</label>
<label>Ziel-Typ <label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)"> <select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option> <option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option> <option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
</select> </select>
@@ -89,22 +102,59 @@
</select> </select>
</label> </label>
</span> </span>
<label>Art <label><span class="value-label">Wert</span> <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute', 'set') %}disabled title="{{ TIP }}"{% endif %}></label>
<select name="kind" data-type="str" onchange="onEntryArtChange(this)"> <label>Gültig ab <input type="date" name="start_date" value="{{ m.start_date.isoformat() if m.start_date else '' }}" {% if m.kind == 'ende' %}disabled title="{{ TIP }}"{% endif %}></label>
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %} <label><span class="bis-label">{{ 'Endet am' if m.kind == 'ende' else 'Gültig bis' }}</span> <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}"></label>
</select>
</label>
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
<button type="submit">Speichern</button> <button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button> <button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
</form> </form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% for ri in row.recurring_items %}
<tr id="sri-row-{{ ri.id }}">
<td>{{ ri.name }}{% if ri.category_id %} <span class="muted">({{ category_names.get(ri.category_id, '') }})</span>{% endif %}</td>
<td>Wiederkehrend ({{ ri.rhythm|de_label }}, am {{ ri.due_day }}.)</td>
<td class="{{ 'neg' if ri.amount < 0 else '' }}">{{ ri.amount|eur }} €</td>
<td>{{ zeitraum(ri.start_date, ri.end_date) }}</td>
<td>
<button type="button" onclick="toggleEdit('sri', {{ ri.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
hx-confirm="„{{ ri.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="sri-edit-{{ ri.id }}" hidden data-sort-follow>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ ri.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ ri.amount|eur }}" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value="">(keine)</option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == ri.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == ri.rhythm %}selected{% endif %}>{{ rh|de_label }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" value="{{ ri.due_day }}" required></label>
<label>Start <input type="date" name="start_date" value="{{ ri.start_date.isoformat() if ri.start_date else '' }}"></label>
<label>Ende <input type="date" name="end_date" value="{{ ri.end_date.isoformat() if ri.end_date else '' }}"></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('sri', {{ ri.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% for sp in row.planned_items %} {% for sp in row.planned_items %}
<tr id="spi-row-{{ sp.id }}"> <tr id="spi-row-{{ sp.id }}">
<td>{{ sp.name }}</td> <td>{{ sp.name }}{% if sp.category_id %} <span class="muted">({{ category_names.get(sp.category_id, '') }})</span>{% endif %}</td>
<td>Einmalzahlung</td> <td>Einmalzahlung</td>
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td> <td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td> <td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
@@ -123,6 +173,12 @@
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label> <label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label> <label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value="">(keine)</option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == sp.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label> <label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
<button type="submit">Speichern</button> <button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button> <button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
@@ -130,28 +186,25 @@
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if not row.modifiers and not row.planned_items %} {% if not row.modifiers and not row.planned_items and not row.recurring_items %}
<tr><td colspan="5">Keine Einträge.</td></tr> <tr><td colspan="5">Keine Einträge.</td></tr>
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
<h4>Neuer Eintrag</h4> <h4>Neue Änderung an Bestehendem</h4>
<form class="entry-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none" <form class="entry-form aenderung-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<div class="entry-art"> <div class="entry-art">
<label>Eintragsart <label>Art
<select name="kind" data-type="str" onchange="onEntryArtChange(this)"> <select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %} {% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
<option value="einmal">Einmalzahlung</option>
</select> </select>
</label> </label>
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span> <span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
</div> </div>
<label>Ziel-Typ <label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)"> <select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
<option value="category">Kategorie</option> <option value="category">Kategorie</option>
<option value="recurring">Wiederkehrender Posten</option> <option value="recurring">Wiederkehrender Posten</option>
</select> </select>
@@ -165,16 +218,48 @@
</span> </span>
<span class="mod-target-recurring" style="display:none"> <span class="mod-target-recurring" style="display:none">
<label>Posten <label>Posten
<select name="target_id" data-type="int" disabled title="Für diese Eintragsart nicht relevant"> <select name="target_id" data-type="int" disabled title="{{ TIP }}">
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %} {% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
</select> </select>
</label> </label>
</span> </span>
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label> <label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label>
<label>Endet am <input type="date" name="end_date" disabled title="Für diese Eintragsart nicht relevant"></label> <label>Gültig ab <input type="date" name="start_date"></label>
<label>Name <input type="text" name="name" disabled title="Für diese Eintragsart nicht relevant"></label> <label><span class="bis-label">Gültig bis</span> <input type="date" name="end_date"></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled title="Für diese Eintragsart nicht relevant"></label> <button type="submit">Hinzufügen</button>
<label>Fällig am <input type="date" name="due" disabled title="Für diese Eintragsart nicht relevant"></label> </form>
<h4>Neue Zahlung nur in diesem Szenario</h4>
<form class="entry-form zahlung-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
data-recurring-url="/api/scenarios/{{ sc.id }}/recurring"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<div class="entry-art">
<label>Art
<select class="zahlung-art" onchange="FBSzenario.zahlungArt(this)">
<option value="einmal">Einmalzahlung</option>
<option value="wiederkehrend">Wiederkehrend</option>
</select>
</label>
<span class="muted">— zählt nur in diesem Szenario, auch ohne „Wiederkehrende Posten/Einmalposten einschließen“.</span>
</div>
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-250,00" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value="">(keine)</option>
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
<label>Fällig am <input type="date" name="due" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str" disabled title="{{ TIP }}">
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh|de_label }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required disabled title="{{ TIP }}"></label>
<label>Start <input type="date" name="start_date" disabled title="{{ TIP }}"></label>
<label>Ende <input type="date" name="end_date" disabled title="{{ TIP }}"></label>
<button type="submit">Hinzufügen</button> <button type="submit">Hinzufügen</button>
</form> </form>
@@ -215,75 +300,6 @@
</section> </section>
<script> <script>
function onModTargetTypeChange(select) {
var form = select.closest('form');
var catSpan = form.querySelector('.mod-target-category');
var recSpan = form.querySelector('.mod-target-recurring');
var catSelect = catSpan.querySelector('select');
var recSelect = recSpan.querySelector('select');
var isCategory = select.value === 'category';
catSpan.style.display = isCategory ? '' : 'none';
recSpan.style.display = isCategory ? 'none' : '';
catSelect.disabled = !isCategory;
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { catSelect.removeAttribute('title'); }
recSelect.disabled = isCategory;
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { recSelect.removeAttribute('title'); }
}
function onEntryArtChange(select) {
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
// kein name/amount/due), wird es uebersprungen.
var form = select.closest('form');
var art = select.value;
var isMod = art !== 'einmal';
function setDisabled(name, disabled) {
var el = form.querySelector('[name="' + name + '"]');
if (el) {
el.disabled = disabled;
if (disabled) { el.title = 'Für diese Eintragsart nicht relevant'; }
else { el.removeAttribute('title'); }
}
}
setDisabled('value', !(art === 'percent' || art === 'absolute'));
var valueLabel = form.querySelector('.value-label');
if (valueLabel) {
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
}
setDisabled('end_date', art !== 'ende');
setDisabled('name', isMod);
setDisabled('amount', isMod);
setDisabled('due', isMod);
var typeSelect = form.querySelector('[name="target_type"]');
if (typeSelect) {
typeSelect.disabled = !isMod;
if (!isMod) { typeSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { typeSelect.removeAttribute('title'); }
var isCategory = typeSelect.value === 'category';
var catSelect = form.querySelector('.mod-target-category select');
var recSelect = form.querySelector('.mod-target-recurring select');
if (catSelect) {
catSelect.disabled = !isMod || !isCategory;
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { catSelect.removeAttribute('title'); }
}
if (recSelect) {
recSelect.disabled = !isMod || isCategory;
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { recSelect.removeAttribute('title'); }
}
}
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
if (form.hasAttribute('data-planned-url')) {
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
: form.getAttribute('data-planned-url'));
htmx.process(form);
}
}
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) { function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' }) fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function (resp) { .then(function (resp) {
+8 -4
View File
@@ -103,12 +103,14 @@
"defaults": { "defaults": {
"unit": "currencyEUR" "unit": "currencyEUR"
}, },
"overrides": [] "overrides": [{ "matcher": { "id": "byType", "options": "time" }, "properties": [{ "id": "unit", "value": "time:MM.YYYY" }] }]
}, },
"options": { "options": {
"legend": { "displayMode": "list", "placement": "bottom" }, "legend": { "displayMode": "list", "placement": "bottom" },
"stacking": "normal", "stacking": "normal",
"xTickLabelRotation": 0 "xTickLabelRotation": 0,
"showValue": "never",
"tooltip": { "mode": "multi", "sort": "asc" }
}, },
"targets": [ "targets": [
{ {
@@ -175,11 +177,13 @@
"title": "Planung nach Kategorie", "title": "Planung nach Kategorie",
"datasource": { "type": "postgres", "uid": "FinanzDB" }, "datasource": { "type": "postgres", "uid": "FinanzDB" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"fieldConfig": { "defaults": { "unit": "currencyEUR" }, "overrides": [] }, "fieldConfig": { "defaults": { "unit": "currencyEUR" }, "overrides": [{ "matcher": { "id": "byType", "options": "time" }, "properties": [{ "id": "unit", "value": "time:MM.YYYY" }] }] },
"options": { "options": {
"legend": { "displayMode": "list", "placement": "bottom" }, "legend": { "displayMode": "list", "placement": "bottom" },
"stacking": "normal", "stacking": "normal",
"xTickLabelRotation": 0 "xTickLabelRotation": 0,
"showValue": "never",
"tooltip": { "mode": "multi", "sort": "asc" }
}, },
"targets": [ "targets": [
{ {
+126
View File
@@ -0,0 +1,126 @@
'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/szenarien.js'), 'utf8');
const TIP = 'Für diese Eintragsart nicht relevant';
const HTML = `
<form id="a" class="entry-form aenderung-form" hx-post="/api/scenarios/1/modifiers">
<select name="kind"><option value="percent">Prozent</option><option value="absolute">Kürzung (€)</option>
<option value="set">Neuer Betrag</option><option value="remove">Entfällt</option><option value="ende">Ende</option></select>
<select name="target_type"><option value="category">Kategorie</option><option value="recurring">Wiederkehrender Posten</option></select>
<span class="mod-target-category"><select name="target_id"><option value="1">K</option></select></span>
<span class="mod-target-recurring" style="display:none"><select name="target_id" disabled><option value="2">P</option></select></span>
<label><span class="value-label">Prozentsatz (%)</span><input name="value"></label>
<input type="date" name="start_date">
<label><span class="bis-label">Gültig bis</span><input type="date" name="end_date"></label>
</form>
<form id="z" class="entry-form zahlung-form" hx-post="/api/scenarios/1/planned"
data-planned-url="/api/scenarios/1/planned" data-recurring-url="/api/scenarios/1/recurring">
<select class="zahlung-art"><option value="einmal">Einmalzahlung</option><option value="wiederkehrend">Wiederkehrend</option></select>
<input name="name"><input name="amount"><select name="category_id"><option value="">(keine)</option></select>
<input type="date" name="due">
<select name="rhythm"><option value="monthly">monatlich</option></select>
<input name="due_day"><input type="date" name="start_date"><input type="date" name="end_date">
</form>`;
function load() {
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
const w = dom.window;
const processed = [];
w.htmx = { process: (el) => processed.push(el.id) };
w.eval(SRC);
w.FBSzenario.init(w.document);
const d = w.document;
return { w, a: d.getElementById('a'), z: d.getElementById('z'), processed };
}
function f(form, name) { return form.querySelector(`[name="${name}"]`); }
function art(w, form, wert) {
const sel = form.querySelector('[name="kind"]');
sel.value = wert;
w.FBSzenario.aenderungArt(sel);
}
test('Prozent: Wert und Zeitraum aktiv', { skip: !JSDOM }, () => {
const { a } = load();
assert.equal(f(a, 'value').disabled, false);
assert.equal(a.querySelector('.value-label').textContent, 'Prozentsatz (%)');
assert.equal(f(a, 'start_date').disabled, false);
assert.equal(f(a, 'end_date').disabled, false);
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
});
test('Kürzung: Label Kürzung (€)', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'absolute');
assert.equal(a.querySelector('.value-label').textContent, 'Kürzung (€)');
});
test('Neuer Betrag: Ziel-Typ fest auf Posten, Kategorie gesperrt', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'set');
const typ = f(a, 'target_type');
assert.equal(typ.value, 'recurring');
assert.equal(typ.querySelector('option[value="category"]').disabled, true);
assert.equal(a.querySelector('.mod-target-recurring select').disabled, false);
assert.equal(a.querySelector('.mod-target-category select').disabled, true);
assert.equal(a.querySelector('.value-label').textContent, 'Neuer Betrag (€)');
art(w, a, 'percent');
assert.equal(typ.querySelector('option[value="category"]').disabled, false);
});
test('Neuer Betrag: Vorzeichen-Hinweis als Platzhalter und Tooltip', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'set');
assert.equal(f(a, 'value').placeholder, '-250,00');
assert.ok(f(a, 'value').title.includes('Vorzeichen'));
art(w, a, 'percent');
assert.equal(f(a, 'value').placeholder, '');
assert.equal(f(a, 'value').hasAttribute('title'), false);
});
test('Ende: Gültig ab und Wert gesperrt, Label Endet am', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'ende');
assert.equal(f(a, 'start_date').disabled, true);
assert.equal(f(a, 'start_date').title, TIP);
assert.equal(f(a, 'value').disabled, true);
assert.equal(f(a, 'end_date').disabled, false);
assert.equal(a.querySelector('.bis-label').textContent, 'Endet am');
});
test('Entfällt: Wert gesperrt, Zeitraum aktiv', { skip: !JSDOM }, () => {
const { w, a } = load();
art(w, a, 'remove');
assert.equal(f(a, 'value').disabled, true);
assert.equal(a.querySelector('.value-label').textContent, 'Wert');
assert.equal(f(a, 'start_date').disabled, false);
assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis');
});
test('Zahlung: Einmal → Wiederkehrend → Einmal', { skip: !JSDOM }, () => {
const { w, z, processed } = load();
const sel = z.querySelector('.zahlung-art');
const wiederkehrend = ['rhythm', 'due_day', 'start_date', 'end_date'];
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
assert.equal(f(z, 'due').disabled, false);
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
sel.value = 'wiederkehrend';
w.FBSzenario.zahlungArt(sel);
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/recurring');
assert.equal(f(z, 'due').disabled, true);
assert.equal(f(z, 'due').title, TIP);
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, false, n));
sel.value = 'einmal';
w.FBSzenario.zahlungArt(sel);
assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned');
wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n));
['name', 'amount', 'category_id'].forEach((n) => assert.equal(f(z, n).disabled, false, n));
assert.ok(processed.includes('z'));
});
+350
View File
@@ -0,0 +1,350 @@
import re
from datetime import date
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from app.engine.loans import add_months
from app.models.tables import (Category, Loan, RecurringItem, Scenario,
ScenarioModifier)
D = Decimal
MONAT = "2030-03"
def _login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
def _daten(db, gehalt="3000.00"):
wohnen, eink, vers = Category(name="Wohnen"), Category(name="Einkommen"), Category(name="Versicherung")
db.add_all([wohnen, eink, vers])
db.flush()
db.add_all([
RecurringItem(name="Gehalt", amount=D(gehalt), rhythm="monthly", due_day=1, category_id=eink.id),
RecurringItem(name="Miete", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=wohnen.id),
RecurringItem(name="Police", amount=D("-1200.00"), rhythm="yearly", due_day=1, category_id=vers.id),
])
db.commit()
return wohnen
def _get(client, **params):
return client.get("/aufteilung", params=params)
def test_menuepunkt(client):
_login(client)
assert '<a href="/aufteilung">Aufteilung</a>' in client.get("/").text
def test_standardauswahl(client):
_login(client)
r = _get(client)
assert r.status_code == 200
assert f'value="{date.today().strftime("%Y-%m")}"' in r.text
assert re.search(r'<option value="">System-Plan</option>', r.text)
assert len(re.findall(r'type="checkbox"[^>]*checked', r.text)) == 3
def test_tabelle_kennzahlen_torte(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
assert "03.2030" in t
assert "Fixposten (jährlich)" in t
assert "-100,00 €" in t # Police 1.200 / 12
assert "33,3 %" in t # Miete 1.000 von 3.000
assert "Gesamte Einkünfte: <strong>3.000,00 €</strong>" in t
tfoot = re.search(r"<tfoot>(.*?)</tfoot>", t, re.S).group(1)
assert "Frei verfügbar" in tfoot and "1.900,00 €" in tfoot and "63,3 %" in tfoot
assert '<svg class="torte-svg"' in t
# Präfix ohne schließendes Anführungszeichen: ab Task 6 folgt „ klickbar".
assert 'class="seg s1' in t and 'class="seg frei"' in t
assert "Anteile an den Einkünften" in t
def test_einnahmezeile_ohne_prozent(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
zeile = re.search(r"<tr>\s*<td[^>]*>Gehalt</td>.*?</tr>", t, re.S).group(0)
assert "%" not in zeile
def test_alle_filter_aus_zeigt_keine_posten(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT, gesendet="1").text
assert "Für diese Auswahl gibt es keine Posten." in t
assert '<form method="get" action="/aufteilung"' in t
assert not re.findall(r'type="checkbox"[^>]*checked', t)
def test_unterdeckung(client, db):
_login(client)
_daten(db, gehalt="500.00")
t = _get(client, monat=MONAT).text
assert "Kosten übersteigen die Einkünfte um 600,00 €" in t
assert "Anteile an den Gesamtkosten" in t
assert 'class="seg frei"' not in t
tfoot = re.search(r"<tfoot>(.*?)</tfoot>", t, re.S).group(1)
assert "Unterdeckung" in tfoot and "-600,00 €" in tfoot
def test_szenario_mit_modifikator(client, db):
_login(client)
wohnen = _daten(db)
sc = Scenario(name="Sparen", description="")
db.add(sc)
db.flush()
db.add(ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=wohnen.id,
kind="percent", value=D("50")))
db.commit()
t = _get(client, monat=MONAT, szenario=str(sc.id)).text
assert "-500,00 €" in t and "Sparen" in t
assert f'<option value="{sc.id}" selected>Sparen</option>' in t
def test_szenario_vergangenheit_fehler_inline(client, db):
_login(client)
_daten(db)
sc = Scenario(name="S", description="")
db.add(sc)
db.commit()
vormonat = add_months(date.today().replace(day=1), -1).strftime("%Y-%m")
r = _get(client, monat=vormonat, szenario=str(sc.id))
assert r.status_code == 200
assert "Szenarien sind erst ab dem laufenden Monat wählbar" in r.text
assert '<form method="get" action="/aufteilung"' in r.text
assert "<table" not in r.text
def test_system_plan_in_vergangenheit_erlaubt(client, db):
_login(client)
_daten(db)
r = _get(client, monat="2020-01")
assert r.status_code == 200 and "<table" in r.text
def test_ungueltiger_monat_und_unbekanntes_szenario(client):
_login(client)
for params, text in (({"monat": "2030-13"}, "Ungültiger Monat"),
({"monat": "abc"}, "Ungültiger Monat"),
({"monat": "0000-01"}, "Ungültiger Monat"),
({"monat": MONAT, "szenario": "²"}, "Unbekanntes Szenario"),
({"monat": MONAT, "szenario": "999"}, "Unbekanntes Szenario"),
({"monat": MONAT, "szenario": "x"}, "Unbekanntes Szenario"),
({"monat": MONAT, "szenario": "9" * 30}, "Unbekanntes Szenario")):
r = _get(client, **params)
assert r.status_code == 200 and text in r.text
assert '<form method="get" action="/aufteilung"' in r.text
def test_doppelzaehlungs_hinweis(client, db):
_login(client)
_daten(db)
db.add_all([
RecurringItem(name="Autorate", amount=D("-100.00"), rhythm="monthly", due_day=15),
Loan(name="Autokredit", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
payout_date=date(2030, 1, 15), repayment_type="annuity"),
])
db.commit()
assert "⚠ Fixposten „Autorate“ zählt evtl. doppelt mit Kredit „Autokredit“" in _get(client, monat=MONAT).text
assert "⚠" not in _get(client, monat=MONAT, gesendet="1", fixposten="1", einmal="1").text
def test_tabelle_scrollbar_torte_darunter(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
assert re.search(r'<div class="tabelle-scroll">\s*<table', t)
assert t.index('id="aufteilung-torte"') > t.index("</table>")
def _drill(client, pfad="/aufteilung/kategorie", **params):
return client.get(pfad, params={"monat": MONAT, **params})
def test_hinweistext_und_segment_links(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
assert "Für Kategorie-Aufteilung auf Segment klicken" in t
assert 'hx-get="/aufteilung/kategorie?' in t
assert "kategorie=Wohnen" in t
assert 'hx-target="#aufteilung-kategorie"' in t
assert '<div id="aufteilung-kategorie">' in t
def test_frei_verfuegbar_ohne_link(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
assert "kategorie=Frei" not in t
frei = re.search(r'<path[^>]*class="seg frei[^"]*"[^>]*>', t).group(0)
assert "hx-get" not in frei
def test_fragment_zweite_torte_und_oob(client, db):
_login(client)
_daten(db)
r = _drill(client, kategorie="Wohnen")
assert r.status_code == 200
assert "Wohnen: 1.000,00 € / Monat" in r.text
assert "Miete" in r.text and "33,3 % der Einkünfte" in r.text
assert '<div id="aufteilung-torte" hx-swap-oob="true">' in r.text
assert "gewaehlt" in r.text
assert "<html" not in r.text
def test_seite_mit_kategorie_zeigt_zweite_torte(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT, kategorie="Versicherung").text
assert "Versicherung: 100,00 € / Monat" in t
assert 'hx-swap-oob' not in t
def test_unbekannte_kategorie_hinweis(client, db):
_login(client)
_daten(db)
for pfad in ("/aufteilung", "/aufteilung/kategorie"):
r = _drill(client, pfad, kategorie="Gibtsnicht")
assert r.status_code == 200
assert "Kategorie „Gibtsnicht“ hat in dieser Auswahl keine Kosten." in r.text
def test_fragment_bei_fehler_nur_hinweis(client):
_login(client)
r = client.get("/aufteilung/kategorie", params={"monat": "2030-13", "kategorie": "X"})
assert r.status_code == 200
assert "Ungültiger Monat" in r.text and "hx-swap-oob" not in r.text
def _viele_kategorien(db):
eink = Category(name="Einkommen")
kleine = [Category(name="Klein A"), Category(name="Klein B")]
gross = Category(name="Groß")
db.add_all([eink, gross, *kleine])
db.flush()
db.add_all([
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
RecurringItem(name="Posten G", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=gross.id),
RecurringItem(name="Posten A", amount=D("-10.00"), rhythm="monthly", due_day=1, category_id=kleine[0].id),
RecurringItem(name="Posten B", amount=D("-20.00"), rhythm="monthly", due_day=1, category_id=kleine[1].id),
])
db.commit()
def test_sammelgruppe_uebrige(client, db):
_login(client)
_viele_kategorien(db)
t = _get(client, monat=MONAT).text
assert "sammel=1" in t
r = _drill(client, sammel="1")
assert "Übrige: 30,00 € / Monat" in r.text
assert "Enthält: Klein B, Klein A" in r.text
assert "Posten A" in r.text and "Posten B" in r.text
def test_echte_kategorie_uebrige_nicht_verwechselt(client, db):
_login(client)
_viele_kategorien(db)
echte = Category(name="Übrige")
db.add(echte)
db.flush()
db.add(RecurringItem(name="Echt", amount=D("-500.00"), rhythm="monthly", due_day=1,
category_id=echte.id))
db.commit()
r = _drill(client, kategorie="Übrige")
assert "Übrige: 500,00 € / Monat" in r.text
assert "Echt" in r.text and "Posten A" not in r.text
r = _drill(client, sammel="1")
assert "Übrige: 30,00 € / Monat" in r.text and "Echt" not in r.text
def test_drilldown_sonderzeichen(client, db):
_login(client)
eink, haus = Category(name="Einkommen"), Category(name="Haus & Garten")
db.add_all([eink, haus])
db.flush()
db.add_all([
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
RecurringItem(name="Rasen", amount=D("-300.00"), rhythm="monthly", due_day=1, category_id=haus.id),
])
db.commit()
t = _get(client, monat=MONAT).text
assert "kategorie=Haus+%26+Garten" in t
r = _drill(client, kategorie="Haus & Garten")
assert "Haus &amp; Garten: 300,00 € / Monat" in r.text and "Rasen" in r.text
def test_monat_vor_jahr_1000_vierstellig(client, db):
_login(client)
eink, wohnen = Category(name="Einkommen"), Category(name="Wohnen")
db.add_all([eink, wohnen])
db.flush()
db.add_all([
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
RecurringItem(name="Miete", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=wohnen.id),
])
db.commit()
r = _get(client, monat="0001-01")
assert r.status_code == 200
assert 'value="0001-01"' in r.text
assert "01.0001" in r.text
assert "monat=0001-01" in r.text
def test_uebrige_mitglieder_in_legende_der_zweiten_torte(client, db):
_login(client)
eink, kat = Category(name="Einkommen"), Category(name="Vielfalt")
db.add_all([eink, kat])
db.flush()
db.add(RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id))
db.add(RecurringItem(name="Großposten", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=kat.id))
for i in range(10):
db.add(RecurringItem(name=f"Kleinposten {i}", amount=D("-5.00"), rhythm="monthly",
due_day=1, category_id=kat.id))
db.commit()
t = _drill(client, kategorie="Vielfalt").text
legende = re.search(r'<ul class="legende">(.*?)</ul>', t, re.S).group(1)
assert 'class="legende-zusatz"' in legende
assert "Kleinposten 3" in legende
CSS = (Path(__file__).resolve().parent.parent / "app" / "static" / "style.css").read_text()
def test_nav_bricht_um():
nav = re.search(r"(?m)^nav\s*\{([^}]*)\}", CSS).group(1)
assert re.search(r"flex-wrap:\s*wrap", nav)
def test_legende_zusatz_nicht_fett_und_figcaption_bleibt_fett():
assert re.search(r"\.legende-zusatz\s*\{[^}]*font-weight:\s*normal", CSS)
# Nutzerwunsch A14: Bildunterschrift der Torte bleibt fett.
assert re.search(r"\.torte figcaption\s*\{[^}]*font-weight:\s*bold", CSS)
def test_gewaehltes_segment_zuletzt_gezeichnet():
from app.routers.gui import templates
def seg(label, gewaehlt):
s = SimpleNamespace(path=f"M0 0 L{label}", color="s1", label=label, merged=False, members=[],
value=Decimal("1.00"), share=Decimal("50.0"))
return {"s": s, "q": None, "gewaehlt": gewaehlt, "zusatz": None}
torte = templates.env.get_template("_torte_macros.html").module.torte
html = str(torte([seg("Alpha", True), seg("Beta", False)], "Titel"))
svg = html.split("<svg", 1)[1].split("</svg>", 1)[0]
assert svg.index("M0 0 LBeta") < svg.index("M0 0 LAlpha")
legende = html.split('<ul class="legende">', 1)[1]
assert legende.index("Alpha") < legende.index("Beta") # Legende behält Reihenfolge
def test_tabellen_scrollen_auf_schmalen_bildschirmen_in_sich():
m = re.search(r"@media \(max-width: 600px\)[^@]*table\s*\{([^}]*)\}", CSS)
assert m and re.search(r"overflow-x:\s*auto", m.group(1)) and re.search(r"display:\s*block", m.group(1))
assert re.search(r"@media \(max-width: 600px\)[^@]*fieldset\s*\{[^}]*min-width:\s*0", CSS)
+196
View File
@@ -0,0 +1,196 @@
from datetime import date
from decimal import Decimal
from app.engine.breakdown import (FREI, BreakdownRow, Filters, PlainLoan,
breakdown_rows, category_rows, share,
summarize)
from app.engine.loans import loan_schedule
from app.engine.scenario import PlainModifier, PlainPlanned, PlainRecurring
M = date(2030, 3, 1)
NAMES = {1: "Wohnen", 2: "Einkommen", 3: "Versicherung"}
D = Decimal
def rows(month=M, recurring=(), planned=(), loans=(), modifiers=(), filters=Filters()):
return breakdown_rows(month, list(recurring), list(planned), list(loans),
list(modifiers), filters, category_names=NAMES,
loan_category="System-Kreditraten",
uncategorized="unkategorisiert")
def rec(id, amount, rhythm="monthly", start=None, end=None, cat=None):
return PlainRecurring(id, f"R{id}", D(amount), rhythm, 1, start, end, cat)
def by_name(rs):
return {r.name: r.amount for r in rs}
def test_fixposten_normalisiert_auf_monat():
rs = rows(recurring=[rec(1, "-1200.00", "yearly"), rec(2, "-100.00", "quarterly"),
rec(3, "-100.00", "yearly"), rec(4, "-50.00")])
assert by_name(rs) == {"R1": D("-100.00"), "R2": D("-33.33"),
"R3": D("-8.33"), "R4": D("-50.00")}
r2 = next(r for r in rs if r.name == "R2")
assert (r2.kind, r2.rhythm, r2.recurring_id, r2.loan_id) == ("recurring", "quarterly", 2, None)
def test_fixposten_laufzeitgrenzen():
rs = rows(recurring=[
rec(1, "-1.00", start=date(2030, 3, 31)), # Start am Monatsletzten: zählt
rec(2, "-1.00", start=date(2030, 4, 1)), # Start im Folgemonat: fehlt
rec(3, "-1.00", end=date(2030, 3, 1)), # Ende am Monatsersten: zählt
rec(4, "-1.00", end=date(2030, 2, 28)), # Ende im Vormonat: fehlt
])
assert sorted(by_name(rs)) == ["R1", "R3"]
def test_ende_modifikator():
mods_vor = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 2, 28))]
mods_in = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 3, 15))]
assert rows(recurring=[rec(1, "-10.00")], modifiers=mods_vor) == []
assert by_name(rows(recurring=[rec(1, "-10.00")], modifiers=mods_in)) == {"R1": D("-10.00")}
def test_modifikatoren_remove_percent_absolute():
mods = [PlainModifier("recurring", 1, "remove", D("0")),
PlainModifier("category", 1, "percent", D("10")),
PlainModifier("recurring", 3, "absolute", D("30"))]
rs = rows(recurring=[rec(1, "-100.00"), rec(2, "-100.00", cat=1), rec(3, "-100.00")],
modifiers=mods)
assert by_name(rs) == {"R2": D("-90.00"), "R3": D("-70.00")}
def test_null_zeilen_entfallen():
mods = [PlainModifier("recurring", 1, "absolute", D("100"))]
assert rows(recurring=[rec(1, "-50.00")], modifiers=mods) == []
def test_einmalposten_voll_nur_im_monat():
planned = [PlainPlanned("E", D("-500.00"), date(2030, 3, 31), 1),
PlainPlanned("F", D("-500.00"), date(2030, 4, 1), 1),
PlainPlanned("G", D("-80.00"), date(2030, 3, 1), None)]
rs = rows(planned=planned, modifiers=[PlainModifier("category", 1, "percent", D("10"))])
assert by_name(rs) == {"E": D("-450.00"), "G": D("-80.00")}
e = next(r for r in rs if r.name == "E")
assert (e.kind, e.category, e.rhythm) == ("planned", "Wohnen", None)
assert next(r for r in rs if r.name == "G").category == "unkategorisiert"
def test_kredit_annuitaet():
k = PlainLoan(7, "Auto", loan_schedule(D("1200.00"), D("0"), 12, date(2030, 1, 15), "annuity"))
rs = rows(loans=[k])
assert len(rs) == 1
assert (rs[0].name, rs[0].category, rs[0].kind, rs[0].amount, rs[0].loan_id) == \
("Auto", "System-Kreditraten", "loan", D("-100.00"), 7)
def test_kredit_endfaellig_schlussrate_voll():
sched = loan_schedule(D("1000.00"), D("12"), 3, date(2030, 1, 10), "bullet")
k = PlainLoan(1, "Endf", sched)
assert by_name(rows(month=date(2030, 3, 1), loans=[k])) == {"Endf": D("-10.00")}
assert by_name(rows(month=date(2030, 4, 1), loans=[k])) == {"Endf": D("-1010.00")}
def test_auszahlung_zaehlt_nicht():
# Auszahlung im März, erste Rate im April: März hat keine Kreditzeile.
k = PlainLoan(1, "K", loan_schedule(D("1200.00"), D("0"), 12, date(2030, 3, 5), "annuity"))
assert rows(loans=[k]) == []
def test_kredit_rate_am_monatsende_geclampt():
k = PlainLoan(1, "K", loan_schedule(D("1200.00"), D("0"), 12, date(2030, 1, 31), "annuity"))
assert by_name(rows(month=date(2030, 2, 1), loans=[k])) == {"K": D("-100.00")}
def test_filter_wirken_auf_einnahmen_und_ausgaben():
recurring = [rec(1, "3000.00", cat=2), rec(2, "-100.00")]
planned = [PlainPlanned("Bonus", D("500.00"), date(2030, 3, 10), 2)]
k = PlainLoan(1, "K", loan_schedule(D("1200.00"), D("0"), 12, date(2030, 1, 15), "annuity"))
assert sorted(by_name(rows(recurring=recurring, planned=planned, loans=[k],
filters=Filters(recurring=False)))) == ["Bonus", "K"]
assert sorted(by_name(rows(recurring=recurring, planned=planned, loans=[k],
filters=Filters(planned=False)))) == ["K", "R1", "R2"]
assert sorted(by_name(rows(recurring=recurring, planned=planned, loans=[k],
filters=Filters(loans=False)))) == ["Bonus", "R1", "R2"]
assert rows(recurring=recurring, planned=planned, loans=[k],
filters=Filters(False, False, False)) == []
def test_sortierung_groesste_kosten_zuerst():
rs = rows(recurring=[rec(1, "3000.00"), rec(2, "-50.00"), rec(3, "-100.00")])
assert [r.name for r in rs] == ["R3", "R2", "R1"]
def test_unbekannte_kategorie_ist_unkategorisiert():
rs = rows(recurring=[rec(1, "-1.00", cat=99), rec(2, "-1.00", cat=None)])
assert {r.category for r in rs} == {"unkategorisiert"}
def _r(name, amount, cat):
return BreakdownRow(name, cat, "recurring", "monthly", D(amount))
def test_summarize_normalfall():
s = summarize([_r("Gehalt", "3000.00", "Einkommen"), _r("Miete", "-1000.00", "Wohnen"),
_r("Strom", "-100.00", "Wohnen"), _r("Police", "-200.00", "Versicherung")])
assert (s.income, s.costs, s.free) == (D("3000.00"), D("1300.00"), D("1700.00"))
assert (s.deficit, s.pie_on_income, s.pie_rest) == (False, True, D("1700.00"))
assert s.category_costs == [("Wohnen", D("1100.00")), ("Versicherung", D("200.00"))]
def test_summarize_unterdeckung():
s = summarize([_r("Gehalt", "1000.00", "Einkommen"), _r("Miete", "-1300.00", "Wohnen")])
assert (s.free, s.deficit, s.pie_on_income, s.pie_rest) == (D("-300.00"), True, False, None)
def test_summarize_ohne_einkuenfte():
s = summarize([_r("Miete", "-100.00", "Wohnen")])
assert (s.income, s.deficit, s.pie_on_income, s.pie_rest) == (D("0"), True, False, None)
assert share(D("-100.00"), s.income) is None
def test_summarize_nur_einkuenfte_und_ausgeglichen():
s = summarize([_r("Gehalt", "500.00", "Einkommen")])
assert (s.costs, s.category_costs, s.pie_rest) == (D("0"), [], D("500.00"))
s = summarize([_r("Gehalt", "500.00", "Einkommen"), _r("Miete", "-500.00", "Wohnen")])
assert (s.free, s.deficit, s.pie_on_income, s.pie_rest) == (D("0.00"), False, True, None)
def test_share_rundung():
assert share(D("-100.00"), D("3000.00")) == D("3.3")
assert share(D("-1"), D("3")) == D("33.3")
assert share(D("-1"), D("16")) == D("6.3") # 6.25 -> HALF_UP
assert share(D("250"), D("1000")) == D("25.0")
def test_category_rows_nur_kosten_der_kategorien():
rs = [_r("Miete", "-1000.00", "Wohnen"), _r("Erstattung", "50.00", "Wohnen"),
_r("Police", "-200.00", "Versicherung"), _r("Strom", "-100.00", "Wohnen")]
assert [r.name for r in category_rows(rs, {"Wohnen"})] == ["Miete", "Strom"]
assert [r.name for r in category_rows(rs, {"Wohnen", "Versicherung"})] == ["Miete", "Police", "Strom"]
assert category_rows(rs, set()) == []
assert category_rows(rs, {FREI}) == []
def test_stichtag_ist_faelligkeitstag_im_monat():
r = PlainRecurring(1, "R1", D("-200.00"), "monthly", 14, None, None, None)
mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))]
assert by_name(rows(month=date(2030, 10, 1), recurring=[r], modifiers=mods)) == {"R1": D("-200.00")}
assert by_name(rows(month=date(2030, 11, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")}
def test_stichtag_monatsende_geklemmt():
# Fällig am 31.: im Februar ist der Stichtag der 28.02. (liegt im Zeitraum).
r = PlainRecurring(1, "R1", D("-100.00"), "monthly", 31, None, None, None)
mods = [PlainModifier("recurring", 1, "remove", D("0"), end_date=date(2030, 2, 28))]
assert rows(month=date(2030, 2, 1), recurring=[r], modifiers=mods) == []
assert by_name(rows(month=date(2030, 3, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")}
def test_szenario_zahlung_ohne_recurring_id():
r = PlainRecurring(None, "S", D("-30.00"), "monthly", 1, None, None, 1)
rs = rows(recurring=[r])
assert by_name(rs) == {"S": D("-30.00")}
assert (rs[0].recurring_id, rs[0].category) == (None, "Wohnen")
+88
View File
@@ -0,0 +1,88 @@
from datetime import date
from decimal import Decimal
from app.engine.breakdown import Filters
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
from app.services.breakdown import PLAN_NAME, compute_breakdown
M = date(2030, 3, 1)
D = Decimal
def _basis(db):
wohnen = Category(name="Wohnen")
db.add(wohnen)
db.flush()
miete = RecurringItem(name="Miete", amount=D("-1000.00"), rhythm="monthly",
due_day=1, category_id=wohnen.id)
gehalt = RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1)
reise = PlannedItem(name="Reise", amount=D("-600.00"), due=date(2030, 3, 20))
k1 = Loan(name="K1", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
payout_date=date(2030, 1, 15), repayment_type="annuity")
k2 = Loan(name="K2", principal=D("2400.00"), annual_rate_pct=D("0"), term_months=12,
payout_date=date(2030, 1, 15), repayment_type="annuity")
db.add_all([miete, gehalt, reise, k1, k2])
db.commit()
return wohnen, miete, k1, k2
def test_plan_name():
assert PLAN_NAME == "System-Plan"
def test_system_plan_alle_kredite_ohne_modifikatoren(db):
wohnen, miete, k1, k2 = _basis(db)
b = compute_breakdown(db, date(2030, 3, 17), None, Filters())
assert b.month == M
assert {r.name: r.amount for r in b.rows} == {
"Miete": D("-1000.00"), "Gehalt": D("3000.00"), "Reise": D("-600.00"),
"K1": D("-100.00"), "K2": D("-200.00")}
assert {r.name: r.category for r in b.rows}["Miete"] == "Wohnen"
assert {r.name: r.category for r in b.rows}["Reise"] == "unkategorisiert"
assert {r.name: r.category for r in b.rows}["K1"] == "System-Kreditraten"
assert b.summary.income == D("3000.00") and b.summary.costs == D("1900.00")
def test_szenario_nur_zugeordnete_kredite_und_flags(db):
wohnen, miete, k1, k2 = _basis(db)
sc = Scenario(name="S", description="", include_recurring=True, include_planned=False)
db.add(sc)
db.flush()
db.add_all([
ScenarioLoan(scenario_id=sc.id, loan_id=k1.id),
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=wohnen.id,
kind="percent", value=D("50")),
ScenarioPlannedItem(scenario_id=sc.id, name="Szenario-Kauf", amount=D("-90.00"),
due=date(2030, 3, 5)),
])
db.commit()
b = compute_breakdown(db, M, sc, Filters())
got = {r.name: (r.amount, r.category) for r in b.rows}
assert got == {"Miete": (D("-500.00"), "Wohnen"), "Gehalt": (D("3000.00"), "unkategorisiert"),
"Szenario-Kauf": (D("-90.00"), "unkategorisiert"),
"K1": (D("-100.00"), "System-Kreditraten")}
def test_szenario_ohne_fixposten(db):
_basis(db)
sc = Scenario(name="S", description="", include_recurring=False, include_planned=True)
db.add(sc)
db.commit()
assert [r.name for r in compute_breakdown(db, M, sc, Filters()).rows] == ["Reise"]
def test_konflikt_nur_wenn_beide_zeilen_enthalten(db):
_basis(db)
rate = RecurringItem(name="Autorate", amount=D("-100.00"), rhythm="monthly", due_day=15)
db.add(rate)
db.commit()
b = compute_breakdown(db, M, None, Filters())
assert [(k.posten_name, k.kredit_name) for k in b.conflicts] == [("Autorate", "K1")]
assert compute_breakdown(db, M, None, Filters(loans=False)).conflicts == []
assert compute_breakdown(db, M, None, Filters(recurring=False)).conflicts == []
sc = Scenario(name="Ohne K1", description="")
db.add(sc)
db.commit()
assert compute_breakdown(db, M, sc, Filters()).conflicts == []
+118 -4
View File
@@ -225,11 +225,11 @@ def test_modifier_ende_validierung(client, db):
"end_date": "2026-08-31"}) "end_date": "2026-08-31"})
assert r.status_code == 201 assert r.status_code == 201
assert r.json()["end_date"] == "2026-08-31" assert r.json()["end_date"] == "2026-08-31"
# andere Arten nullen end_date # andere Arten behalten end_date als „Gültig bis"
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={ r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove", "target_type": "recurring", "target_id": 1, "kind": "remove",
"end_date": "2026-08-31"}) "end_date": "2026-08-31"})
assert r.status_code == 201 and r.json()["end_date"] is None assert r.status_code == 201 and r.json()["end_date"] == "2026-08-31"
def test_scenario_planned_crud_und_cleanup(client, db): def test_scenario_planned_crud_und_cleanup(client, db):
@@ -269,11 +269,11 @@ def test_modifier_patch(client, db):
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={ r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende"}) "target_type": "recurring", "target_id": 1, "kind": "ende"})
assert r.status_code == 422 assert r.status_code == 422
# zurueck auf remove: end_date wird genullt # zurueck auf remove: end_date bleibt als „Gültig bis" erhalten
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={ r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove", "target_type": "recurring", "target_id": 1, "kind": "remove",
"end_date": "2026-08-31"}) "end_date": "2026-08-31"})
assert r.status_code == 200 and r.json()["end_date"] is None assert r.status_code == 200 and r.json()["end_date"] == "2026-08-31"
# fremdes Szenario -> 404 # fremdes Szenario -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json() other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={ r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
@@ -318,3 +318,117 @@ def test_szenario_name_system_ist_ist_reserviert_patch(client):
def test_szenario_normale_namen_weiter_erlaubt(client): def test_szenario_normale_namen_weiter_erlaubt(client):
assert client.post("/api/scenarios", json={"name": "Ist-Zustand plus"}, assert client.post("/api/scenarios", json={"name": "Ist-Zustand plus"},
headers=H).status_code == 201 headers=H).status_code == 201
def _msgs(r):
return " ".join(e["msg"] for e in r.json()["detail"])
def test_modifier_zeitraum_und_neuer_betrag(client):
sc = client.post("/api/scenarios", headers=H, json={"name": "Zeitraum"}).json()
url = f"/api/scenarios/{sc['id']}/modifiers"
r = client.post(url, headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
"start_date": "2026-10-16", "end_date": "2027-03-31"})
assert r.status_code == 201
assert (r.json()["start_date"], r.json()["end_date"]) == ("2026-10-16", "2027-03-31")
r = client.post(url, headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
"start_date": "2027-04-01", "end_date": "2027-03-31"})
assert r.status_code == 422 and "»Gültig ab« liegt nach »Gültig bis«" in _msgs(r)
r = client.post(url, headers=H, json={
"target_type": "category", "target_id": 1, "kind": "set", "value": "-80.00"})
assert r.status_code == 422 and "Art »Neuer Betrag« ist nur für Posten möglich" in _msgs(r)
r = client.post(url, headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "set", "value": "-137.50",
"start_date": "2026-10-16"})
assert r.status_code == 201
assert (r.json()["kind"], r.json()["value"], r.json()["end_date"]) == ("set", "-137.50", None)
r = client.post(url, headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende",
"start_date": "2026-10-01", "end_date": "2026-10-15"})
assert r.status_code == 201
assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-10-15")
def test_modifier_patch_artwechsel_zeitraum(client):
sc = client.post("/api/scenarios", headers=H, json={"name": "Artwechsel"}).json()
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
"start_date": "2026-10-16", "end_date": "2027-03-31"}).json()
url = f"/api/scenarios/{sc['id']}/modifiers/{m['id']}"
r = client.patch(url, headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende",
"start_date": "2026-10-16", "end_date": "2026-12-31"})
assert r.status_code == 200
assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-12-31")
r = client.patch(url, headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "10",
"end_date": "2026-12-31"})
assert r.status_code == 200
assert (r.json()["kind"], r.json()["start_date"], r.json()["end_date"]) == \
("percent", None, "2026-12-31")
def test_scenario_recurring_crud_und_cleanup(client, db):
from app.models.tables import Category, ScenarioRecurringItem
cat = Category(name="Wohnen-SR")
db.add(cat)
db.commit()
sc = client.post("/api/scenarios", headers=H, json={"name": "SR-CRUD"}).json()
url = f"/api/scenarios/{sc['id']}/recurring"
body = {"name": "Miete neu", "amount": "-137.50", "rhythm": "monthly",
"due_day": 14, "start_date": "2026-10-16", "category_id": cat.id}
r = client.post(url, headers=H, json=body)
assert r.status_code == 201
item = r.json()
assert (item["scenario_id"], item["amount"], item["end_date"], item["category_id"]) == \
(sc["id"], "-137.50", None, cat.id)
assert [i["name"] for i in client.get(url, headers=H).json()] == ["Miete neu"]
r = client.patch(f"{url}/{item['id']}", headers=H, json={
"name": "Miete neu", "amount": "-250.00", "rhythm": "quarterly", "due_day": 1,
"start_date": None, "end_date": "2027-12-31", "category_id": None})
assert r.status_code == 200
assert (r.json()["amount"], r.json()["rhythm"], r.json()["category_id"]) == \
("-250.00", "quarterly", None)
# Validierung wie globale Fixposten
assert client.post(url, headers=H, json={**body, "due_day": 0}).status_code == 422
assert client.post(url, headers=H, json={**body, "rhythm": "weekly"}).status_code == 422
assert client.post(url, headers=H, json={**body, "end_date": "2026-10-01"}).status_code == 422
r = client.post(url, headers=H, json={**body, "category_id": 9999})
assert r.status_code == 404 and r.json()["detail"] == "Kategorie nicht gefunden"
# fremdes/fehlendes Szenario
other = client.post("/api/scenarios", headers=H, json={"name": "SR-Other"}).json()
r = client.delete(f"/api/scenarios/{other['id']}/recurring/{item['id']}", headers=H)
assert r.status_code == 404 and r.json()["detail"] == "Wiederkehrende Zahlung nicht gefunden"
assert client.get("/api/scenarios/99999/recurring", headers=H).status_code == 404
# Szenario löschen räumt mit ab (kein FK-Fehler)
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
assert db.query(ScenarioRecurringItem).count() == 0
def test_scenario_recurring_delete(client):
sc = client.post("/api/scenarios", headers=H, json={"name": "SR-Del"}).json()
url = f"/api/scenarios/{sc['id']}/recurring"
item = client.post(url, headers=H, json={
"name": "X", "amount": "-1.00", "rhythm": "monthly", "due_day": 1}).json()
assert client.delete(f"{url}/{item['id']}", headers=H).status_code == 204
assert client.get(url, headers=H).json() == []
def test_scenario_planned_mit_kategorie(client, db):
from app.models.tables import Category
cat = Category(name="Kat-SPI")
db.add(cat)
db.commit()
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-Kat"}).json()
url = f"/api/scenarios/{sc['id']}/planned"
r = client.post(url, headers=H, json={
"name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": cat.id})
assert r.status_code == 201 and r.json()["category_id"] == cat.id
assert client.post(url, headers=H, json={
"name": "E", "amount": "-5.00", "due": "2026-11-01",
"category_id": 9999}).status_code == 404
r = client.patch(f"{url}/{r.json()['id']}", headers=H, json={
"name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": None})
assert r.status_code == 200 and r.json()["category_id"] is None
+3 -2
View File
@@ -35,9 +35,10 @@ def test_de_label_bekannte_und_unbekannte_werte():
assert de_label("annuity") == "Annuität" assert de_label("annuity") == "Annuität"
assert de_label("bullet") == "endfällig" assert de_label("bullet") == "endfällig"
assert de_label("percent") == "Prozent" assert de_label("percent") == "Prozent"
assert de_label("absolute") == "Absolut" assert de_label("absolute") == "Kürzung (€)"
assert de_label("set") == "Neuer Betrag"
assert de_label("remove") == "Entfällt" assert de_label("remove") == "Entfällt"
assert de_label("ende") == "Ende" assert de_label("ende") == "Ende"
assert de_label("weirdvalue") == "weirdvalue" assert de_label("weirdvalue") == "weirdvalue"
assert set(DE_LABELS) == {"monthly", "quarterly", "yearly", "annuity", assert set(DE_LABELS) == {"monthly", "quarterly", "yearly", "annuity",
"bullet", "percent", "absolute", "remove", "ende"} "bullet", "percent", "absolute", "set", "remove", "ende"}
+19
View File
@@ -69,3 +69,22 @@ def test_panels_ueberlappen_nicht_und_ids_eindeutig():
for y in range(g["y"], g["y"] + g["h"]): for y in range(g["y"], g["y"] + g["h"]):
assert (x, y) not in cells, p["title"] assert (x, y) not in cells, p["title"]
cells.add((x, y)) cells.add((x, y))
BARCHARTS = ("Monat nach Kategorie", "Planung nach Kategorie")
def test_barcharts_ohne_segment_beschriftung_mit_voller_aufschluesselung():
"""Gestapelte Balken mit vielen Kategorien: Segment-Beschriftungen
überlagern sich (sichtbar bleibt ein zufälliger kleiner Wert). Daher keine
Werte im Balken, stattdessen alle Kategorien im Tooltip, größte Ausgabe
zuerst, und den Monat ohne Uhrzeit (Monatserster UTC wirkte in MEZ wie
01:00 Uhr)."""
panels = {p["title"]: p for p in _load()["panels"]}
for title in BARCHARTS:
p = panels[title]
assert p["options"]["showValue"] == "never", title
assert p["options"]["tooltip"] == {"mode": "multi", "sort": "asc"}, title
overrides = p["fieldConfig"]["overrides"]
assert {"matcher": {"id": "byType", "options": "time"},
"properties": [{"id": "unit", "value": "time:MM.YYYY"}]} in overrides, title
+91 -7
View File
@@ -4,22 +4,29 @@ from decimal import Decimal
from app.config import get_settings from app.config import get_settings
from app.formats import eur from app.formats import eur
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem, from app.models.tables import (Account, Category, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem) Scenario, ScenarioModifier, ScenarioPlannedItem,
ScenarioRecurringItem)
H = {"Authorization": "Bearer test-key"} H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client): def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"): for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
r = client.get(path, follow_redirects=False) r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login" assert r.headers["location"] == "/login"
def test_aufteilung_fragment_erfordert_login(client):
r = client.get("/aufteilung/kategorie", follow_redirects=False)
assert r.status_code in (302, 303)
assert r.headers["location"] == "/login"
def test_pages_render_after_login(client): def test_pages_render_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"}) client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"): for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
r = client.get(path) r = client.get(path)
assert r.status_code == 200 assert r.status_code == 200
assert "Finanzberatung" in r.text assert "Finanzberatung" in r.text
@@ -426,7 +433,7 @@ def test_szenario_eintraege_tabelle(client, db):
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute # Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
assert 'value="einmal"' in r assert 'value="einmal"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "onEntryArtChange" in r and "onModKindChange" not in r assert "FBSzenario.aenderungArt" in r and "onEntryArtChange" not in r
def test_szenarien_seite_und_nav(client, db): def test_szenarien_seite_und_nav(client, db):
@@ -453,7 +460,7 @@ def test_neuer_eintrag_formular_struktur(client, db):
db.add(Scenario(name="UX-Test", description="")) db.add(Scenario(name="UX-Test", description=""))
db.commit() db.commit()
r = client.get("/szenarien").text r = client.get("/szenarien").text
assert 'class="entry-form"' in r assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
assert 'class="entry-art"' in r # Eintragsart-Zeile assert 'class="entry-art"' in r # Eintragsart-Zeile
assert 'class="value-label"' in r # dynamisches Wert-Label assert 'class="value-label"' in r # dynamisches Wert-Label
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
@@ -593,7 +600,8 @@ def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
def test_base_hat_doppelzaehlungs_dialog(client): def test_base_hat_doppelzaehlungs_dialog(client):
client.post("/login", data={"username": "admin", "password": "geheim"}) client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text html = client.get("/").text
assert '<script src="/static/doppelzaehlung.js"></script>' in html from app.version import get_version
assert f'<script src="/static/doppelzaehlung.js?v={get_version()}"></script>' in html
assert '<dialog id="doppelzaehlung-dialog">' in html assert '<dialog id="doppelzaehlung-dialog">' in html
assert 'id="dz-abbrechen" autofocus' in html assert 'id="dz-abbrechen" autofocus' in html
assert ">Trotzdem speichern</button>" in html assert ">Trotzdem speichern</button>" in html
@@ -631,3 +639,79 @@ def test_vorschlag_formular_nutzt_json_form_post(client):
from pathlib import Path from pathlib import Path
src = Path("app/templates/planning.html").read_text(encoding="utf-8") 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) assert re.search(r'class="inline-form" hx-ext="json-form" hx-post="/api/recurring"', src)
def test_statische_dateien_mit_versionskennung(client):
# Cache-Busting: jede neue VERSION erzwingt frische CSS/JS im Browser,
# sonst zeigen gecachte Altstände z. B. schwarze Tortensegmente.
from app.version import get_version
v = get_version()
login = client.get("/login").text
assert f'href="/static/style.css?v={v}"' in login
assert re.search(r'/static/[^"?]+"', login) is None
client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text
for datei in ("style.css", "htmx.min.js", "sort.js", "doppelzaehlung.js", "szenarien.js"):
assert f'/static/{datei}?v={v}"' in html
assert re.search(r'/static/[^"?]+"', html) is None
def test_szenario_zwei_formulare(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Formulare-A14", description="")
db.add(sc)
db.commit()
r = client.get("/szenarien").text
assert "Neue Änderung an Bestehendem" in r and "Neue Zahlung nur in diesem Szenario" in r
assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
assert f'hx-post="/api/scenarios/{sc.id}/modifiers"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert f'data-recurring-url="/api/scenarios/{sc.id}/recurring"' in r
assert 'value="set">Neuer Betrag<' in r and 'value="absolute">Kürzung (€)<' in r
for feld in ('name="start_date"', 'name="end_date"', 'name="rhythm"',
'name="due_day"', 'name="category_id"', 'name="due"'):
assert feld in r, feld
assert 'class="zahlung-art"' in r and '<option value="">(keine)</option>' in r
assert 'class="bis-label"' in r
def test_szenario_eintraege_zeitraum_und_wiederkehrend(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
cat = Category(name="Kat-Anzeige")
sc = Scenario(name="Anzeige-A14", description="")
db.add_all([cat, sc])
db.flush()
db.add_all([
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
kind="percent", value=Decimal("50"), start_date=date(2026, 10, 16)),
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
kind="set", value=Decimal("-80.00"),
start_date=date(2026, 11, 1), end_date=date(2027, 3, 31)),
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
kind="remove", value=Decimal("0"), end_date=date(2027, 6, 30)),
ScenarioRecurringItem(scenario_id=sc.id, name="Zahlung-W", amount=Decimal("-137.50"),
rhythm="monthly", due_day=14, start_date=date(2026, 10, 16),
category_id=cat.id),
ScenarioPlannedItem(scenario_id=sc.id, name="Zahlung-E", amount=Decimal("-5.00"),
due=date(2026, 12, 1), category_id=cat.id),
])
db.commit()
r = client.get("/szenarien").text
assert "<th>Zeitraum</th>" in r
assert "ab 16.10.2026" in r and "01.11.2026 – 31.03.2027" in r and "bis 30.06.2027" in r
assert ">Neuer Betrag<" in r and "-80,00" in r
assert "Zahlung-W" in r and "Wiederkehrend (monatlich, am 14.)" in r and "-137,50" in r
assert f'hx-patch="/api/scenarios/{sc.id}/recurring/' in r
assert f'hx-delete="/api/scenarios/{sc.id}/recurring/' in r
assert "Kat-Anzeige" in r and "Zahlung-E" in r
assert 'id="sri-row-' in r and 'id="sri-edit-' in r
def test_planung_laufzeit_felder_haben_max_1200(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Loan(name="MaxL", principal=Decimal("1000.00"),
annual_rate_pct=Decimal("4.50"), term_months=12,
payout_date=date(2026, 8, 1), repayment_type="annuity"))
db.commit()
r = client.get("/planung").text
assert r.count('max="1200"') >= 2
+18
View File
@@ -27,3 +27,21 @@ def test_bullet_loan():
assert all(i.principal == 0 for i in plan[:-1]) assert all(i.principal == 0 for i in plan[:-1])
assert plan[-1].principal == Decimal("10000") assert plan[-1].principal == Decimal("10000")
assert plan[-1].remaining == Decimal("0.00") assert plan[-1].remaining == Decimal("0.00")
def test_schedule_for_entspricht_loan_schedule():
from types import SimpleNamespace
from app.engine.loans import schedule_for
kredit = SimpleNamespace(principal="1200.00", annual_rate_pct="3.00", term_months=12,
payout_date=date(2030, 1, 31), repayment_type="annuity")
assert schedule_for(kredit) == loan_schedule(Decimal("1200.00"), Decimal("3.00"), 12,
date(2030, 1, 31), "annuity")
def test_keine_manuelle_tilgungsplan_konvertierung_mehr():
from pathlib import Path
app_dir = Path(__file__).resolve().parent.parent / "app"
engine_loans = app_dir / "engine" / "loans.py"
treffer = [str(p.relative_to(app_dir)) for p in app_dir.rglob("*.py")
if p != engine_loans and "loan_schedule(Decimal(" in p.read_text()]
assert treffer == []
+79
View File
@@ -0,0 +1,79 @@
"""Postgres-only: Alembic-Revision e7f1a9c3b214 (Ausbaustufe 14) hoch/runter
auf einer Wegwerf-DB (Name muss auf '_test' enden). Beispiel:
podman run -d --rm --name a14-pg -e POSTGRES_PASSWORD=wegwerf \
-e POSTGRES_DB=fb_test -p 127.0.0.1:55432:5432 docker.io/library/postgres:17.10
FB_TEST_PG_URL=postgresql+psycopg://postgres:wegwerf@127.0.0.1:55432/fb_test \
.venv/bin/python -m pytest -q tests/test_migration_pg.py
"""
import os
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import make_url
PG_URL = os.environ.get("FB_TEST_PG_URL")
pytestmark = pytest.mark.skipif(not PG_URL, reason="FB_TEST_PG_URL nicht gesetzt")
ROOT = Path(__file__).resolve().parent.parent
@pytest.fixture
def alembic_cfg(monkeypatch):
assert make_url(PG_URL).database.endswith("_test"), "nur Wegwerf-DB *_test!"
monkeypatch.setenv("FB_DATABASE_URL", PG_URL)
from app.config import get_settings
get_settings.cache_clear()
engine = create_engine(PG_URL)
with engine.begin() as conn:
conn.execute(text("DROP SCHEMA public CASCADE; CREATE SCHEMA public"))
cfg = Config(str(ROOT / "alembic.ini"))
cfg.set_main_option("script_location", str(ROOT / "alembic"))
yield cfg, engine
# Schema leeren, damit test_views_pg.py (create_all) nicht auf dem
# herabgestuften Alembic-Stand aufsetzt.
with engine.begin() as conn:
conn.execute(text("DROP SCHEMA public CASCADE; CREATE SCHEMA public"))
engine.dispose()
get_settings.cache_clear()
def test_migration_a14_up_down(alembic_cfg):
cfg, engine = alembic_cfg
command.upgrade(cfg, "d5e8b3c7a410")
with engine.begin() as conn:
conn.execute(text("INSERT INTO scenarios (id, name, description, include_recurring, "
"include_planned) VALUES (1, 'S', '', true, true)"))
conn.execute(text(
"INSERT INTO scenario_modifiers (id, scenario_id, target_type, target_id, kind, "
"value, end_date) VALUES "
"(1, 1, 'recurring', 7, 'ende', 0, '2030-10-15'), "
"(2, 1, 'category', 3, 'percent', 10, NULL)"))
command.upgrade(cfg, "head")
with engine.begin() as conn:
rows = conn.execute(text("SELECT id, kind, value, start_date, end_date "
"FROM scenario_modifiers ORDER BY id")).all()
assert [(r.id, r.kind, str(r.value), r.start_date, str(r.end_date))
for r in rows] == [(1, "ende", "0.00", None, "2030-10-15"),
(2, "percent", "10.00", None, "None")]
conn.execute(text(
"INSERT INTO scenario_modifiers (id, scenario_id, target_type, target_id, kind, "
"value, start_date, end_date) VALUES "
"(3, 1, 'recurring', 7, 'set', -80, '2030-11-01', NULL), "
"(4, 1, 'recurring', 7, 'percent', 50, '2030-11-01', '2030-12-31')"))
conn.execute(text(
"INSERT INTO scenario_recurring_items (scenario_id, name, amount, rhythm, due_day) "
"VALUES (1, 'W', -10, 'monthly', 14)"))
insp = inspect(engine)
assert "scenario_recurring_items" in insp.get_table_names()
assert "category_id" in {c["name"] for c in insp.get_columns("scenario_planned_items")}
command.downgrade(cfg, "d5e8b3c7a410")
with engine.begin() as conn:
rows = conn.execute(text("SELECT id, kind, end_date FROM scenario_modifiers "
"ORDER BY id")).all()
assert [(r.id, r.kind, str(r.end_date)) for r in rows] == [
(1, "ende", "2030-10-15"), (2, "percent", "None"), (4, "percent", "None")]
insp = inspect(engine)
assert "scenario_recurring_items" not in insp.get_table_names()
assert "start_date" not in {c["name"] for c in insp.get_columns("scenario_modifiers")}
+35
View File
@@ -0,0 +1,35 @@
"""Alembic-Revision e7f1a9c3b214 (A14) muss auch auf SQLite hoch/runter/hoch laufen."""
from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect
ROOT = Path(__file__).resolve().parent.parent
def test_migration_a14_sqlite_up_down_up(tmp_path, monkeypatch):
url = f"sqlite:///{tmp_path / 'mig.db'}"
monkeypatch.setenv("FB_DATABASE_URL", url)
from app.config import get_settings
get_settings.cache_clear()
try:
cfg = Config(str(ROOT / "alembic.ini"))
cfg.set_main_option("script_location", str(ROOT / "alembic"))
engine = create_engine(url)
def state():
insp = inspect(engine)
return ("scenario_recurring_items" in insp.get_table_names(),
"category_id" in {c["name"] for c in
insp.get_columns("scenario_planned_items")})
command.upgrade(cfg, "head")
assert state() == (True, True)
command.downgrade(cfg, "d5e8b3c7a410")
assert state() == (False, False)
command.upgrade(cfg, "head")
assert state() == (True, True)
engine.dispose()
finally:
get_settings.cache_clear()
+26
View File
@@ -34,3 +34,29 @@ def test_scenario_planned_item_roundtrip(db):
db.commit() db.commit()
item = db.query(ScenarioPlannedItem).one() item = db.query(ScenarioPlannedItem).one()
assert item.amount == Decimal("5000.00") and item.scenario_id == sc.id assert item.amount == Decimal("5000.00") and item.scenario_id == sc.id
def test_a14_szenario_felder_roundtrip(db):
from app.models.tables import (Category, Scenario, ScenarioModifier,
ScenarioPlannedItem, ScenarioRecurringItem)
cat = Category(name="A14-Kat")
sc = Scenario(name="A14-Modell", description="")
db.add_all([cat, sc])
db.flush()
db.add_all([
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
kind="set", value=Decimal("-80.00"),
start_date=date(2030, 2, 1), end_date=date(2030, 3, 31)),
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-5.00"),
due=date(2030, 1, 1), category_id=cat.id),
ScenarioRecurringItem(scenario_id=sc.id, name="W", amount=Decimal("-10.00"),
rhythm="monthly", due_day=14,
start_date=date(2030, 1, 16), end_date=None,
category_id=cat.id),
])
db.commit()
m = db.query(ScenarioModifier).one()
assert (m.kind, m.start_date, m.end_date) == ("set", date(2030, 2, 1), date(2030, 3, 31))
assert db.query(ScenarioPlannedItem).one().category_id == cat.id
w = db.query(ScenarioRecurringItem).one()
assert (w.scenario_id, w.rhythm, w.due_day, w.category_id) == (sc.id, "monthly", 14, cat.id)
+92
View File
@@ -0,0 +1,92 @@
from decimal import Decimal
import pytest
from app.engine.pie import UEBRIGE, pie_slices
D = Decimal
def test_leer_und_nullwerte():
assert pie_slices([]) == []
assert pie_slices([("A", D("0")), ("B", D("-5"))]) == []
def test_segmente_lueckenlos_und_anteile():
sl = pie_slices([("C", D("20")), ("A", D("50")), ("B", D("30"))])
assert [s.label for s in sl] == ["A", "B", "C"]
assert [s.color for s in sl] == ["s1", "s2", "s3"]
assert [s.share for s in sl] == [D("50.0"), D("30.0"), D("20.0")]
assert sl[0].start == 0.0 and sl[-1].end == pytest.approx(1.0)
for a, b in zip(sl, sl[1:]):
assert a.end == pytest.approx(b.start)
assert sl[0].path.startswith("M 0 0 L 0.0000 -1.0000 A 1 1 0 0 1 ")
def test_grosses_segment_als_zwei_teilboegen():
a, b = pie_slices([("A", D("70")), ("B", D("30"))])
assert a.path.count(" A 1 1 0 0 1 ") == 2
assert b.path.count(" A 1 1 0 0 1 ") == 1
assert " 0 1 1 " not in a.path
def test_fast_volles_segment_bleibt_sichtbar():
gross, klein = pie_slices([("Gross", D("4999.99"))], rest=D("0.01"),
rest_label="Frei verfügbar")
# Zwei Teilbögen: der Zwischenpunkt liegt gegenüber (unten), der Bogen
# kann also nie zu einem Punkt zusammenfallen.
assert gross.path.count(" A 1 1 0 0 1 ") == 2
assert " A 1 1 0 0 1 0.0000 1.0000 " in gross.path or " A 1 1 0 0 1 -0.0000 1.0000 " in gross.path
assert klein.path is not None
def test_letztes_segment_schliesst_bei_zwoelf_uhr():
sl = pie_slices([("A", D("50")), ("B", D("30")), ("C", D("20"))])
assert sl[-1].path.endswith(" 0.0000 -1.0000 Z") or sl[-1].path.endswith(" -0.0000 -1.0000 Z")
def test_einzelsegment_ist_vollkreis():
(s,) = pie_slices([("A", D("12.34"))])
assert s.path is None and s.share == D("100.0") and s.color == "s1"
def test_kleine_werte_zu_uebrige():
sl = pie_slices([("A", D("95")), ("B", D("2")), ("C", D("2")), ("D", D("1"))])
assert [s.label for s in sl] == ["A", UEBRIGE]
u = sl[1]
assert (u.value, u.color, u.merged, u.members) == (D("5"), "uebrige", True, ("B", "C", "D"))
def test_einzelner_kleiner_wert_behaelt_namen():
sl = pie_slices([("A", D("98")), ("B", D("2"))])
assert [(s.label, s.color, s.merged) for s in sl] == [("A", "s1", False), ("B", "uebrige", False)]
def test_hoechstens_acht_benannte_segmente():
sl = pie_slices([(f"K{i}", D("10")) for i in range(10)], rest=D("5"),
rest_label="Frei verfügbar")
assert [s.color for s in sl] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8",
"uebrige", "frei"]
assert sl[-2].merged and sl[-2].members == ("K8", "K9")
def test_acht_kategorien_ohne_uebrige():
sl = pie_slices([(f"K{i}", D("10")) for i in range(8)])
assert [s.color for s in sl] == [f"s{i}" for i in range(1, 9)]
assert not any(s.merged for s in sl)
def test_rest_wird_nie_zusammengefasst():
sl = pie_slices([("A", D("99.5"))], rest=D("0.5"), rest_label="Frei verfügbar")
assert [(s.label, s.color, s.merged) for s in sl] == [("A", "s1", False), ("Frei verfügbar", "frei", False)]
assert sl[1].share == D("0.5")
def test_nur_rest_ist_vollkreis():
(s,) = pie_slices([], rest=D("100"), rest_label="Frei verfügbar")
assert (s.label, s.color, s.path) == ("Frei verfügbar", "frei", None)
def test_rest_none_oder_null_ignoriert():
assert len(pie_slices([("A", D("1"))], rest=None)) == 1
assert len(pie_slices([("A", D("1"))], rest=D("0"), rest_label="X")) == 1
+41
View File
@@ -2,6 +2,8 @@ from datetime import date
from decimal import Decimal from decimal import Decimal
from itertools import count from itertools import count
import pytest
from app.models.tables import Account, Category, RecurringItem, Transaction from app.models.tables import Account, Category, RecurringItem, Transaction
from app.services.suggestions import _Series, _try_merge, suggest_recurring from app.services.suggestions import _Series, _try_merge, suggest_recurring
@@ -368,3 +370,42 @@ def test_suggest_volatilitaetshinweis_bei_abgetrennter_neuester_buchung(db):
assert len(out) == 1 assert len(out) == 1
assert out[0]["amount"] == Decimal("-200.00") # Betrag NICHT durch die 600er-Buchung verfaelscht assert out[0]["amount"] == Decimal("-200.00") # Betrag NICHT durch die 600er-Buchung verfaelscht
assert "schwanken" in out[0]["hinweis"].lower() assert "schwanken" in out[0]["hinweis"].lower()
# ----------------------------------------------------------- Ausbaustufe 14: Task 5
KREDIT = {"name": "Laufzeit-Test", "principal": "1000.00", "annual_rate_pct": "2.00",
"payout_date": "2030-01-01", "repayment_type": "annuity"}
@pytest.mark.parametrize("laufzeit", [0, -5, 1201])
def test_kredit_laufzeit_ausserhalb_1_bis_1200(client, laufzeit):
r = client.post("/api/loans", headers=H, json={**KREDIT, "term_months": laufzeit})
assert r.status_code == 422
def test_kredit_laufzeit_grenzen_und_patch(client):
a = client.post("/api/loans", headers=H, json={**KREDIT, "term_months": 1})
b = client.post("/api/loans", headers=H, json={**KREDIT, "name": "L2", "term_months": 1200})
assert (a.status_code, b.status_code) == (201, 201)
url = f"/api/loans/{a.json()['id']}"
assert client.patch(url, headers=H, json={"term_months": 0}).status_code == 422
assert client.patch(url, headers=H, json={"term_months": 1201}).status_code == 422
r = client.patch(url, headers=H, json={"term_months": 1200})
assert r.status_code == 200 and r.json()["term_months"] == 1200
def test_altbestand_kredit_mit_laufzeit_ausserhalb_grenzen(client, db):
"""Regression: Kredite die vor Validierung direkt in DB eingefügt wurden
(term_months>1200 möglich) dürfen GET /api/loans nicht crashen.
LoanOut-Ausgabe muss Altbestände ohne Validierung unterstützen."""
from app.models.tables import Loan
from decimal import Decimal
old_loan = Loan(name="Legacy", principal=Decimal("500.00"), annual_rate_pct=Decimal("2.0"),
term_months=1500, payout_date=date(2025, 1, 1), repayment_type="annuity")
db.add(old_loan)
db.commit()
r = client.get("/api/loans", headers=H)
assert r.status_code == 200
loans = r.json()
found = [l for l in loans if l["term_months"] == 1500]
assert len(found) == 1 and found[0]["name"] == "Legacy"
+7
View File
@@ -27,3 +27,10 @@ def test_monthly_leap_year_clamping():
def test_quarterly_clamping(): def test_quarterly_clamping():
got = occurrences("quarterly", 31, date(2026, 1, 1), date(2026, 12, 31)) got = occurrences("quarterly", 31, date(2026, 1, 1), date(2026, 12, 31))
assert got == [date(2026, 1, 31), date(2026, 4, 30), date(2026, 7, 31), date(2026, 10, 31)] assert got == [date(2026, 1, 31), date(2026, 4, 30), date(2026, 7, 31), date(2026, 10, 31)]
def test_due_in_month_klemmt_auf_monatsende():
from app.engine.recurrence import due_in_month
assert due_in_month(date(2030, 2, 1), 31) == date(2030, 2, 28)
assert due_in_month(date(2032, 2, 10), 30) == date(2032, 2, 29)
assert due_in_month(date(2030, 10, 1), 14) == date(2030, 10, 14)
+67
View File
@@ -105,3 +105,70 @@ def test_ende_modifier_aendert_betraege_nicht():
m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31)) m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31))
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31)) flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31))
assert all(a == Decimal("-2000.00") for _, a in flows) assert all(a == Decimal("-2000.00") for _, a in flows)
D = Decimal
def _rec(amount="-100.00", due_day=14, id=1, cat=None):
return PlainRecurring(id, "R", D(amount), "monthly", due_day, None, None, cat)
def test_zeitraum_grenzen_inklusiv():
mods = [PlainModifier("recurring", 1, "percent", D("50"),
end_date=date(2030, 3, 14), start_date=date(2030, 2, 14))]
flows = build_cashflows([_rec()], [], [], [], mods, date(2030, 1, 1), date(2030, 4, 30))
assert flows == [(date(2030, 1, 14), D("-100.00")),
(date(2030, 2, 14), D("-50.00")),
(date(2030, 3, 14), D("-50.00")),
(date(2030, 4, 14), D("-100.00"))]
def test_ab_stichtag_erst_folgemonat():
# Fällig am 14., gültig ab 16.10. -> Oktober voll, ab November halbiert.
mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))]
flows = build_cashflows([_rec("-200.00")], [], [], [], mods,
date(2030, 10, 1), date(2030, 12, 31))
assert flows == [(date(2030, 10, 14), D("-200.00")),
(date(2030, 11, 14), D("-100.00")),
(date(2030, 12, 14), D("-100.00"))]
def test_neuer_betrag_dann_prozent():
mods = [PlainModifier("recurring", 1, "set", D("-80.00"), start_date=date(2030, 2, 1)),
PlainModifier("recurring", 1, "percent", D("25"))]
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
date(2030, 1, 1), date(2030, 2, 28))
assert flows == [(date(2030, 1, 1), D("-75.00")), (date(2030, 2, 1), D("-60.00"))]
def test_entfaellt_nur_im_zeitraum():
mods = [PlainModifier("recurring", 1, "remove", D("0"),
end_date=date(2030, 2, 28), start_date=date(2030, 2, 1))]
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
date(2030, 1, 1), date(2030, 3, 31))
assert [d for d, _ in flows] == [date(2030, 1, 1), date(2030, 3, 1)]
def test_szenario_zahlung_ohne_id_nur_von_kategorie_getroffen():
rec = [PlainRecurring(None, "S", D("-100.00"), "monthly", 1, None, None, 7)]
mods = [PlainModifier("recurring", 1, "remove", D("0")),
PlainModifier("category", 7, "percent", D("10"))]
flows = build_cashflows(rec, [], [], [], mods, date(2030, 1, 1), date(2030, 1, 31))
assert flows == [(date(2030, 1, 1), D("-90.00"))]
def test_einmalposten_stichtag_ist_faelligkeit():
planned = [PlainPlanned("E", D("-100.00"), date(2030, 1, 31), 7),
PlainPlanned("F", D("-100.00"), date(2030, 2, 1), 7)]
mods = [PlainModifier("category", 7, "percent", D("50"), start_date=date(2030, 2, 1))]
flows = build_cashflows([], planned, [], [], mods, date(2030, 1, 1), date(2030, 2, 28))
assert flows == [(date(2030, 1, 31), D("-100.00")), (date(2030, 2, 1), D("-50.00"))]
def test_ende_unveraendert_mit_zeitraum_feldern():
# "ende" wirkt weiter nur über effective_end, nie als Betragsänderung.
mods = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 2, 15))]
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
date(2030, 1, 1), date(2030, 4, 30))
assert flows == [(date(2030, 1, 1), D("-100.00")), (date(2030, 2, 1), D("-100.00"))]
+111
View File
@@ -0,0 +1,111 @@
from datetime import date
from decimal import Decimal
from app.engine.scenario import (PlainModifier, PlainRecurring, build_cashflows,
effective_end, modified_amount)
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, ScenarioRecurringItem)
from app.services.projection_service import load_scenario_inputs
def test_modifikator_helfer_oeffentlich():
mods = [PlainModifier("recurring", 1, "percent", Decimal("10")),
PlainModifier("recurring", 1, "ende", Decimal("0"), date(2030, 6, 30))]
assert modified_amount(Decimal("-100.00"), None, 1, mods, on=date(2030, 1, 1)) == Decimal("-90.00")
assert modified_amount(Decimal("-100.00"), None, 2, mods, on=date(2030, 1, 1)) == Decimal("-100.00")
r = PlainRecurring(1, "R", Decimal("-100.00"), "monthly", 1, None,
date(2030, 12, 31), None)
assert effective_end(r, mods) == date(2030, 6, 30)
def _daten(db):
rec = RecurringItem(name="R", amount=Decimal("-10.00"), rhythm="monthly", due_day=1)
pln = PlannedItem(name="P", amount=Decimal("-20.00"), due=date(2030, 1, 1))
k1 = Loan(name="K1", principal=Decimal("100.00"), annual_rate_pct=Decimal("0"),
term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity")
k2 = Loan(name="K2", principal=Decimal("200.00"), annual_rate_pct=Decimal("0"),
term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity")
sc = Scenario(name="S", description="", include_recurring=True,
include_planned=False)
db.add_all([rec, pln, k1, k2, sc])
db.flush()
db.add_all([
ScenarioLoan(scenario_id=sc.id, loan_id=k1.id),
ScenarioModifier(scenario_id=sc.id, target_type="recurring",
target_id=rec.id, kind="percent", value=Decimal("50")),
ScenarioPlannedItem(scenario_id=sc.id, name="SP", amount=Decimal("-5.00"),
due=date(2030, 2, 1)),
])
db.commit()
return sc
def test_load_scenario_inputs_szenario(db):
sc = _daten(db)
inp = load_scenario_inputs(db, sc)
assert [r.name for r in inp.recurring] == ["R"]
# include_planned=False: globale Einmalposten fehlen, Szenario-eigene zählen immer.
assert [p.name for p in inp.planned] == ["SP"]
assert inp.planned[0].category_id is None
assert [l.name for l in inp.loans] == ["K1"]
assert [(m.kind, m.value) for m in inp.modifiers] == [("percent", Decimal("50"))]
def test_load_scenario_inputs_ohne_fixposten(db):
sc = _daten(db)
sc.include_recurring = False
db.commit()
assert load_scenario_inputs(db, sc).recurring == []
def test_load_scenario_inputs_system_plan(db):
_daten(db)
inp = load_scenario_inputs(db, None)
assert [r.name for r in inp.recurring] == ["R"]
assert [p.name for p in inp.planned] == ["P"]
assert [l.name for l in inp.loans] == ["K1", "K2"]
assert inp.modifiers == []
def test_szenario_zahlung_zaehlt_immer_und_id_kollision(db):
rec = RecurringItem(name="R", amount=Decimal("-10.00"), rhythm="monthly", due_day=1)
sc = Scenario(name="S-A14", description="", include_recurring=False,
include_planned=False)
db.add_all([rec, sc])
db.flush()
sri = ScenarioRecurringItem(scenario_id=sc.id, name="SR", amount=Decimal("-100.00"),
rhythm="monthly", due_day=1)
db.add(sri)
db.flush()
# Posten-Eintrag, dessen target_id zufällig der ID der Szenario-Zahlung entspricht:
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
target_id=sri.id, kind="remove", value=Decimal("0")))
db.commit()
inp = load_scenario_inputs(db, sc)
# include_recurring=False: globaler Posten fehlt, Szenario-Zahlung zählt, id=None.
assert [(r.id, r.name) for r in inp.recurring] == [(None, "SR")]
flows = build_cashflows(inp.recurring, inp.planned, [], [], inp.modifiers,
date(2030, 1, 1), date(2030, 1, 31))
assert flows == [(date(2030, 1, 1), Decimal("-100.00"))]
# System-Plan kennt keine Szenario-Zahlungen.
assert [r.name for r in load_scenario_inputs(db, None).recurring] == ["R"]
def test_zeitraum_und_kategorie_werden_geladen(db):
cat = Category(name="K-A14")
sc = Scenario(name="S-A14b", description="")
db.add_all([cat, sc])
db.flush()
db.add_all([
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
kind="percent", value=Decimal("50"),
start_date=date(2030, 2, 1), end_date=date(2030, 3, 31)),
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-5.00"),
due=date(2030, 2, 1), category_id=cat.id),
])
db.commit()
inp = load_scenario_inputs(db, sc)
m = inp.modifiers[0]
assert (m.start_date, m.end_date) == (date(2030, 2, 1), date(2030, 3, 31))
assert [(p.name, p.category_id) for p in inp.planned] == [("E", cat.id)]
+13 -1
View File
@@ -17,7 +17,8 @@ def _follow_ids(html):
def test_base_bindet_sort_js_ein(client): def test_base_bindet_sort_js_ein(client):
_login(client) _login(client)
assert '<script src="/static/sort.js"></script>' in client.get("/").text from app.version import get_version
assert f'<script src="/static/sort.js?v={get_version()}"></script>' in client.get("/").text
def test_planung_folgezeilen_markiert(client, db): def test_planung_folgezeilen_markiert(client, db):
@@ -70,3 +71,14 @@ def test_index_aktionsspalten_ohne_sortierung(client):
def test_buchungen_tabelle_ist_serverseitig(client): def test_buchungen_tabelle_ist_serverseitig(client):
_login(client) _login(client)
assert 'data-sort-mode="server"' in client.get("/buchungen").text assert 'data-sort-mode="server"' in client.get("/buchungen").text
def test_aufteilung_tabelle_sortierbar_tfoot_fest(client, db):
_login(client)
db.add(RecurringItem(name="R", amount=Decimal("-1.00"), rhythm="monthly", due_day=1))
db.add(RecurringItem(name="G", amount=Decimal("10.00"), rhythm="monthly", due_day=1))
db.commit()
t = client.get("/aufteilung", params={"monat": "2030-03"}).text
table = re.search(r"<table[^>]*>.*?</table>", t, re.S).group(0)
assert 'data-sort-mode="server"' not in table
assert re.search(r"</tbody>\s*<tfoot>", table)