Compare commits

...
14 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
38 changed files with 3063 additions and 194 deletions
+25 -7
View File
@@ -28,7 +28,7 @@ flowchart TD
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
@@ -47,10 +47,10 @@ 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_breakdown["breakdown.py<br/>breakdown_rows, summarize,<br/>category_rows, share"]
E_pie["pie.py<br/>pie_slices"] E_pie["pie.py<br/>pie_slices"]
end end
@@ -67,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
@@ -179,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
@@ -326,9 +326,27 @@ Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`,
ist die einzige Brücke: `load_scenario_inputs(session, scenario)` ist die ist die einzige Brücke: `load_scenario_inputs(session, scenario)` ist die
gemeinsame Ladefunktion (`None` = System-Plan, ohne Szenario-Änderungen) gemeinsame Ladefunktion (`None` = System-Plan, ohne Szenario-Änderungen)
und lädt ORM-Objekte (`Loan`, `RecurringItem`, und lädt ORM-Objekte (`Loan`, `RecurringItem`,
`PlannedItem`, `Scenario`, `ScenarioModifier`), baut daraus die Plain-Objekte `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
File diff suppressed because it is too large Load Diff
@@ -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.13.1 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')
+6 -4
View File
@@ -3,7 +3,8 @@
Rein (kein DB-Zugriff). Fixposten werden auf den Monat normalisiert Rein (kein DB-Zugriff). Fixposten werden auf den Monat normalisiert
(quartalsweise /3, jährlich /12), Einmalposten und Kreditraten zählen voll (quartalsweise /3, jährlich /12), Einmalposten und Kreditraten zählen voll
im Monat ihrer Fälligkeit; die Kredit-Auszahlung zählt nie. Modifikatoren im Monat ihrer Fälligkeit; die Kredit-Auszahlung zählt nie. Modifikatoren
wirken exakt wie in build_cashflows (gleiche Helfer). wirken wie in build_cashflows (gleiche Helfer); Stichtag je Fixposten ist sein
Fälligkeitstag im Monat, je Einmalposten das Fälligkeitsdatum.
""" """
import calendar import calendar
from collections import defaultdict from collections import defaultdict
@@ -12,7 +13,7 @@ from datetime import date
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from app.engine.loans import Installment from app.engine.loans import Installment
from app.engine.recurrence import STEP from app.engine.recurrence import STEP, due_in_month
from app.engine.scenario import (CENT, PlainModifier, PlainPlanned, from app.engine.scenario import (CENT, PlainModifier, PlainPlanned,
PlainRecurring, effective_end, modified_amount) PlainRecurring, effective_end, modified_amount)
@@ -77,7 +78,8 @@ def breakdown_rows(month: date, recurring: list[PlainRecurring],
end = effective_end(r, modifiers) end = effective_end(r, modifiers)
if end is not None and end < first: if end is not None and end < first:
continue continue
amount = modified_amount(r.amount, r.category_id, r.id, modifiers) amount = modified_amount(r.amount, r.category_id, r.id, modifiers,
on=due_in_month(first, r.due_day))
if amount is None: if amount is None:
continue continue
monthly = (amount / STEP[r.rhythm]).quantize(CENT, ROUND_HALF_UP) monthly = (amount / STEP[r.rhythm]).quantize(CENT, ROUND_HALF_UP)
@@ -87,7 +89,7 @@ def breakdown_rows(month: date, recurring: list[PlainRecurring],
for p in planned: for p in planned:
if not first <= p.due <= last: if not first <= p.due <= last:
continue continue
amount = modified_amount(p.amount, p.category_id, None, modifiers) amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due)
if amount is None: if amount is None:
continue continue
out.append(BreakdownRow(p.name, cat(p.category_id), "planned", None, out.append(BreakdownRow(p.name, cat(p.category_id), "planned", None,
+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)
+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]
+32 -17
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 _trifft(m: PlainModifier, category_id: int | None, recurring_id: int | None) -> bool:
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, def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None,
modifiers: list[PlainModifier]) -> Decimal | 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:
@@ -57,14 +74,11 @@ def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int
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)
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) 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_amount(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)):
amount = modified_amount(r.amount, r.category_id, r.id, modifiers, on=d)
if amount is not None:
flows.append((d, amount)) flows.append((d, amount))
for p in planned: for p in planned:
amount = modified_amount(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:
+2 -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",
} }
+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):
+9 -2
View File
@@ -21,7 +21,8 @@ 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,
Scenario, 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
@@ -322,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
@@ -378,7 +384,8 @@ 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"],
}) })
+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]
+78 -6
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).
if self.kind == "ende":
if self.end_date is None:
raise ValueError("Art »Ende« erfordert ein Datum") raise ValueError("Art »Ende« erfordert ein Datum")
if self.kind != "ende": self.start_date = None
self.end_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()
+2 -6
View File
@@ -5,14 +5,13 @@ Modifikatoren) - dieselbe Datenbasis wie load_scenario_inputs(None).
""" """
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import Decimal
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.engine.breakdown import (BreakdownRow, Filters, PlainLoan, Summary, from app.engine.breakdown import (BreakdownRow, Filters, PlainLoan, Summary,
breakdown_rows, summarize) breakdown_rows, summarize)
from app.engine.loans import loan_schedule from app.engine.loans import schedule_for
from app.models.tables import Category, Scenario from app.models.tables import Category, Scenario
from app.reserved import SYSTEM_PREFIX from app.reserved import SYSTEM_PREFIX
from app.services.double_count import Konflikt, all_conflicts from app.services.double_count import Konflikt, all_conflicts
@@ -34,10 +33,7 @@ def compute_breakdown(session: Session, month: date, scenario: Scenario | None,
filters: Filters) -> Breakdown: filters: Filters) -> Breakdown:
inputs = load_scenario_inputs(session, scenario) inputs = load_scenario_inputs(session, scenario)
names = {c.id: c.name for c in session.execute(select(Category)).scalars()} names = {c.id: c.name for c in session.execute(select(Category)).scalars()}
loans = [PlainLoan(l.id, l.name, loans = [PlainLoan(l.id, l.name, schedule_for(l)) for l in inputs.loans]
loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
l.term_months, l.payout_date, l.repayment_type))
for l in inputs.loans]
rows = breakdown_rows(month, inputs.recurring, inputs.planned, loans, rows = breakdown_rows(month, inputs.recurring, inputs.planned, loans,
inputs.modifiers, filters, category_names=names, inputs.modifiers, filters, category_names=names,
loan_category=KREDITRATEN, uncategorized=UNKATEGORISIERT) loan_category=KREDITRATEN, uncategorized=UNKATEGORISIERT)
+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
+16 -10
View File
@@ -8,14 +8,14 @@ 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
@@ -31,7 +31,8 @@ class ScenarioInputs:
def load_scenario_inputs(session: Session, scenario: Scenario | None) -> ScenarioInputs: def load_scenario_inputs(session: Session, scenario: Scenario | None) -> ScenarioInputs:
"""Lädt die Eingaben eines Szenarios. scenario=None ist die reine """Lädt die Eingaben eines Szenarios. scenario=None ist die reine
Planung („System-Plan"): alle Fixposten, alle globalen Einmalposten, Planung („System-Plan"): alle Fixposten, alle globalen Einmalposten,
alle Kredite, keine Modifikatoren, keine Szenario-Einmalposten.""" alle Kredite, keine Modifikatoren, keine Szenario-Einmalposten und keine
szenario-eigenen wiederkehrenden Zahlungen."""
recurring = [] recurring = []
if scenario is None or scenario.include_recurring: if scenario is None or scenario.include_recurring:
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm, recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
@@ -46,9 +47,16 @@ def load_scenario_inputs(session: Session, scenario: Scenario | None) -> Scenari
if scenario is None: if scenario is None:
loans = list(session.execute(select(Loan).order_by(Loan.id)).scalars()) loans = list(session.execute(select(Loan).order_by(Loan.id)).scalars())
return ScenarioInputs(recurring, planned, loans, []) return ScenarioInputs(recurring, planned, loans, [])
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario - # Szenario-eigene Zahlungen zählen IMMER für dieses Szenario -
# include_planned steuert nur die globalen PlannedItems. # include_recurring/include_planned steuern nur die globalen Posten.
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None) # 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( for p in session.execute(select(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario.id) ScenarioPlannedItem.scenario_id == scenario.id)
.order_by(ScenarioPlannedItem.id)).scalars()] .order_by(ScenarioPlannedItem.id)).scalars()]
@@ -57,7 +65,7 @@ def load_scenario_inputs(session: Session, scenario: Scenario | None) -> Scenari
.where(ScenarioLoan.scenario_id == scenario.id) .where(ScenarioLoan.scenario_id == scenario.id)
.order_by(Loan.id)).scalars()) .order_by(Loan.id)).scalars())
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind, modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
Decimal(m.value), m.end_date) Decimal(m.value), m.end_date, m.start_date)
for m in session.execute(select(ScenarioModifier).where( for m in session.execute(select(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario.id) ScenarioModifier.scenario_id == scenario.id)
.order_by(ScenarioModifier.id)).scalars()] .order_by(ScenarioModifier.id)).scalars()]
@@ -68,9 +76,7 @@ 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)
inputs = load_scenario_inputs(session, scenario) inputs = load_scenario_inputs(session, scenario)
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct), schedules = [schedule_for(l) for l in inputs.loans]
l.term_months, l.payout_date, l.repayment_type)
for l in inputs.loans]
payouts = [(l.payout_date, Decimal(l.principal)) for l in inputs.loans] payouts = [(l.payout_date, Decimal(l.principal)) for l in inputs.loans]
flows = build_cashflows(inputs.recurring, inputs.planned, schedules, payouts, flows = build_cashflows(inputs.recurring, inputs.planned, schedules, payouts,
inputs.modifiers, start_date, end) inputs.modifiers, start_date, end)
+9
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;
@@ -278,4 +286,5 @@ label:has(input:disabled), label:has(select:disabled) {
.torte-svg .seg.klickbar:hover { opacity: 0.85; } .torte-svg .seg.klickbar:hover { opacity: 0.85; }
.torte-svg .seg.gewaehlt { stroke: #222; stroke-width: 0.05; } .torte-svg .seg.gewaehlt { stroke: #222; stroke-width: 0.05; }
.legende li.gewaehlt { font-weight: bold; } .legende li.gewaehlt { font-weight: bold; }
.legende-zusatz { font-weight: normal; color: #666; }
#aufteilung-kategorie h2 { font-size: 1.1rem; margin-top: 1.5rem; } #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 };
})();
+3 -1
View File
@@ -13,7 +13,9 @@ class="seg {{ seg.s.color }}{% if seg.q %} klickbar{% endif %}{% if seg.gewaehlt
<figure class="torte"> <figure class="torte">
<figcaption>{{ titel }}</figcaption> <figcaption>{{ titel }}</figcaption>
<svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}"> <svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}">
{% for seg in segmente %}{% set s = seg.s %} {# 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> {% 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 %} {% 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 %} {% endfor %}
+2
View File
@@ -8,6 +8,7 @@
<script src="/static/htmx.min.js?v={{ app_version }}"></script> <script src="/static/htmx.min.js?v={{ app_version }}"></script>
<script src="/static/sort.js?v={{ app_version }}"></script> <script src="/static/sort.js?v={{ app_version }}"></script>
<script src="/static/doppelzaehlung.js?v={{ app_version }}"></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>
@@ -100,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>
+18 -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
+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) {
+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'));
});
+38
View File
@@ -1,6 +1,8 @@
import re import re
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from app.engine.loans import add_months from app.engine.loans import add_months
from app.models.tables import (Category, Loan, RecurringItem, Scenario, from app.models.tables import (Category, Loan, RecurringItem, Scenario,
@@ -310,3 +312,39 @@ def test_uebrige_mitglieder_in_legende_der_zweiten_torte(client, db):
legende = re.search(r'<ul class="legende">(.*?)</ul>', t, re.S).group(1) legende = re.search(r'<ul class="legende">(.*?)</ul>', t, re.S).group(1)
assert 'class="legende-zusatz"' in legende assert 'class="legende-zusatz"' in legende
assert "Kleinposten 3" 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)
+22
View File
@@ -172,3 +172,25 @@ def test_category_rows_nur_kosten_der_kategorien():
assert [r.name for r in category_rows(rs, {"Wohnen", "Versicherung"})] == ["Miete", "Police", "Strom"] assert [r.name for r in category_rows(rs, {"Wohnen", "Versicherung"})] == ["Miete", "Police", "Strom"]
assert category_rows(rs, set()) == [] assert category_rows(rs, set()) == []
assert category_rows(rs, {FREI}) == [] 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")
+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"}
+67 -5
View File
@@ -4,8 +4,9 @@ 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"}
@@ -432,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):
@@ -459,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
@@ -650,6 +651,67 @@ def test_statische_dateien_mit_versionskennung(client):
assert re.search(r'/static/[^"?]+"', login) is None assert re.search(r'/static/[^"?]+"', login) is None
client.post("/login", data={"username": "admin", "password": "geheim"}) client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text html = client.get("/").text
for datei in ("style.css", "htmx.min.js", "sort.js", "doppelzaehlung.js"): for datei in ("style.css", "htmx.min.js", "sort.js", "doppelzaehlung.js", "szenarien.js"):
assert f'/static/{datei}?v={v}"' in html assert f'/static/{datei}?v={v}"' in html
assert re.search(r'/static/[^"?]+"', html) is None 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)
+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"))]
+50 -7
View File
@@ -1,19 +1,19 @@
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from app.engine.scenario import (PlainModifier, PlainRecurring, effective_end, from app.engine.scenario import (PlainModifier, PlainRecurring, build_cashflows,
modified_amount) effective_end, modified_amount)
from app.models.tables import (Loan, PlannedItem, RecurringItem, Scenario, from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
ScenarioLoan, ScenarioModifier, Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem) ScenarioPlannedItem, ScenarioRecurringItem)
from app.services.projection_service import load_scenario_inputs from app.services.projection_service import load_scenario_inputs
def test_modifikator_helfer_oeffentlich(): def test_modifikator_helfer_oeffentlich():
mods = [PlainModifier("recurring", 1, "percent", Decimal("10")), mods = [PlainModifier("recurring", 1, "percent", Decimal("10")),
PlainModifier("recurring", 1, "ende", Decimal("0"), date(2030, 6, 30))] PlainModifier("recurring", 1, "ende", Decimal("0"), date(2030, 6, 30))]
assert modified_amount(Decimal("-100.00"), None, 1, mods) == Decimal("-90.00") 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) == Decimal("-100.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, r = PlainRecurring(1, "R", Decimal("-100.00"), "monthly", 1, None,
date(2030, 12, 31), None) date(2030, 12, 31), None)
assert effective_end(r, mods) == date(2030, 6, 30) assert effective_end(r, mods) == date(2030, 6, 30)
@@ -66,3 +66,46 @@ def test_load_scenario_inputs_system_plan(db):
assert [p.name for p in inp.planned] == ["P"] assert [p.name for p in inp.planned] == ["P"]
assert [l.name for l in inp.loans] == ["K1", "K2"] assert [l.name for l in inp.loans] == ["K1", "K2"]
assert inp.modifiers == [] 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)]