Compare commits
14
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
4c3fa25cd0 | ||
|
|
d51287e8d8 | ||
|
|
eb68781a1d | ||
|
|
30330801cc | ||
|
|
dfb814955f | ||
|
|
94fd7ab96e | ||
|
|
80b8b369c7 | ||
|
|
cd7ba6aa6a | ||
|
|
8911623c15 | ||
|
|
2aed685d7e | ||
|
|
aaf05b9209 | ||
|
|
b0a1ba80a1 | ||
|
|
c065ade0d5 | ||
|
|
d8dc25f2a8 |
+25
-7
@@ -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
@@ -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')
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,15 +74,12 @@ 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)
|
end = m.end_date if end is None else min(end, m.end_date)
|
||||||
or (m.target_type == "recurring" and r.id == m.target_id))
|
|
||||||
if hit:
|
|
||||||
end = m.end_date if end is None else min(end, m.end_date)
|
|
||||||
return end
|
return end
|
||||||
|
|
||||||
|
|
||||||
@@ -76,15 +90,16 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
|
|||||||
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
||||||
flows: list[tuple[date, Decimal]] = []
|
flows: list[tuple[date, Decimal]] = []
|
||||||
for r in recurring:
|
for r in recurring:
|
||||||
amount = modified_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)):
|
||||||
flows.append((d, amount))
|
amount = modified_amount(r.amount, r.category_id, r.id, modifiers, on=d)
|
||||||
|
if amount is not None:
|
||||||
|
flows.append((d, amount))
|
||||||
for p in planned:
|
for p in planned:
|
||||||
amount = modified_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:
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ from app.config import get_settings
|
|||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||||
Scenario, ScenarioLoan, ScenarioModifier,
|
Scenario, ScenarioLoan, ScenarioModifier,
|
||||||
ScenarioPlannedItem)
|
ScenarioPlannedItem, ScenarioRecurringItem)
|
||||||
|
from app.routers.planning import RecurringIn, _check_category
|
||||||
from app.reserved import check_reserved_name
|
from app.reserved import check_reserved_name
|
||||||
from app.services.projection_service import run_projection
|
from app.services.projection_service import run_projection
|
||||||
|
|
||||||
@@ -47,16 +48,23 @@ class ScenarioPatch(BaseModel):
|
|||||||
class ModifierIn(BaseModel):
|
class ModifierIn(BaseModel):
|
||||||
target_type: Literal["category", "recurring"]
|
target_type: Literal["category", "recurring"]
|
||||||
target_id: int
|
target_id: int
|
||||||
kind: Literal["percent", "absolute", "remove", "ende"]
|
kind: Literal["percent", "absolute", "set", "remove", "ende"]
|
||||||
value: Decimal = Decimal("0")
|
value: Decimal = Decimal("0")
|
||||||
|
start_date: date | None = None
|
||||||
end_date: date | None = None
|
end_date: date | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _ende_braucht_datum(self):
|
def _art_und_zeitraum(self):
|
||||||
if self.kind == "ende" and self.end_date is None:
|
# end_date = Enddatum bei "ende", sonst „Gültig bis" (A14).
|
||||||
raise ValueError("Art »Ende« erfordert ein Datum")
|
if self.kind == "ende":
|
||||||
if self.kind != "ende":
|
if self.end_date is None:
|
||||||
self.end_date = None
|
raise ValueError("Art »Ende« erfordert ein Datum")
|
||||||
|
self.start_date = None
|
||||||
|
if self.kind == "set" and self.target_type != "recurring":
|
||||||
|
raise ValueError("Art »Neuer Betrag« ist nur für Posten möglich")
|
||||||
|
if (self.start_date is not None and self.end_date is not None
|
||||||
|
and self.start_date > self.end_date):
|
||||||
|
raise ValueError("»Gültig ab« liegt nach »Gültig bis«")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +135,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
|
|||||||
scenario = _get_scenario(session, scenario_id)
|
scenario = _get_scenario(session, scenario_id)
|
||||||
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
|
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
|
||||||
# foreign-key violations (would surface as a 500 on Postgres).
|
# foreign-key violations (would surface as a 500 on Postgres).
|
||||||
|
session.execute(delete(ScenarioRecurringItem).where(
|
||||||
|
ScenarioRecurringItem.scenario_id == scenario_id))
|
||||||
session.execute(delete(ProjectionPoint).where(
|
session.execute(delete(ProjectionPoint).where(
|
||||||
ProjectionPoint.scenario_id == scenario_id))
|
ProjectionPoint.scenario_id == scenario_id))
|
||||||
session.execute(delete(ProjectionResult).where(
|
session.execute(delete(ProjectionResult).where(
|
||||||
@@ -203,6 +213,7 @@ class ScenarioPlannedIn(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
due: date
|
due: date
|
||||||
|
category_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class ScenarioPlannedOut(ScenarioPlannedIn):
|
class ScenarioPlannedOut(ScenarioPlannedIn):
|
||||||
@@ -225,6 +236,7 @@ def list_scenario_planned(scenario_id: int, session: Session = Depends(get_sessi
|
|||||||
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
|
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
|
||||||
session: Session = Depends(get_session)):
|
session: Session = Depends(get_session)):
|
||||||
_get_scenario(session, scenario_id)
|
_get_scenario(session, scenario_id)
|
||||||
|
_check_category(session, data.category_id)
|
||||||
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
|
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
|
||||||
session.add(item)
|
session.add(item)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -248,6 +260,7 @@ def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlanned
|
|||||||
session: Session = Depends(get_session)):
|
session: Session = Depends(get_session)):
|
||||||
# Full-Body-Update, Muster patch_modifier.
|
# Full-Body-Update, Muster patch_modifier.
|
||||||
_get_scenario(session, scenario_id)
|
_get_scenario(session, scenario_id)
|
||||||
|
_check_category(session, data.category_id)
|
||||||
item = session.get(ScenarioPlannedItem, item_id)
|
item = session.get(ScenarioPlannedItem, item_id)
|
||||||
if item is None or item.scenario_id != scenario_id:
|
if item is None or item.scenario_id != scenario_id:
|
||||||
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
||||||
@@ -278,3 +291,62 @@ def project_scenario(scenario_id: int, horizon_days: int | None = None,
|
|||||||
below_threshold_date=result.below_threshold_date,
|
below_threshold_date=result.below_threshold_date,
|
||||||
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
|
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioRecurringOut(RecurringIn):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
scenario_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def _get_scenario_recurring(session: Session, scenario_id: int,
|
||||||
|
item_id: int) -> ScenarioRecurringItem:
|
||||||
|
item = session.get(ScenarioRecurringItem, item_id)
|
||||||
|
if item is None or item.scenario_id != scenario_id:
|
||||||
|
raise HTTPException(404, "Wiederkehrende Zahlung nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{scenario_id}/recurring", response_model=list[ScenarioRecurringOut])
|
||||||
|
def list_scenario_recurring(scenario_id: int, session: Session = Depends(get_session)):
|
||||||
|
_get_scenario(session, scenario_id)
|
||||||
|
return [ScenarioRecurringOut.model_validate(r) for r in session.execute(
|
||||||
|
select(ScenarioRecurringItem)
|
||||||
|
.where(ScenarioRecurringItem.scenario_id == scenario_id)
|
||||||
|
.order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{scenario_id}/recurring", response_model=ScenarioRecurringOut,
|
||||||
|
status_code=201)
|
||||||
|
def add_scenario_recurring(scenario_id: int, data: RecurringIn,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
# Bewusst ohne Doppelzählungs-Prüfung (gilt nur für globale Fixposten).
|
||||||
|
_get_scenario(session, scenario_id)
|
||||||
|
_check_category(session, data.category_id)
|
||||||
|
item = ScenarioRecurringItem(scenario_id=scenario_id, **data.model_dump())
|
||||||
|
session.add(item)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return ScenarioRecurringOut.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{scenario_id}/recurring/{item_id}", response_model=ScenarioRecurringOut)
|
||||||
|
def patch_scenario_recurring(scenario_id: int, item_id: int, data: RecurringIn,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
# Full-Body-Update, Muster patch_scenario_planned.
|
||||||
|
_get_scenario(session, scenario_id)
|
||||||
|
item = _get_scenario_recurring(session, scenario_id, item_id)
|
||||||
|
_check_category(session, data.category_id)
|
||||||
|
for key, value in data.model_dump().items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return ScenarioRecurringOut.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{scenario_id}/recurring/{item_id}", status_code=204)
|
||||||
|
def delete_scenario_recurring(scenario_id: int, item_id: int,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
_get_scenario(session, scenario_id)
|
||||||
|
session.delete(_get_scenario_recurring(session, scenario_id, item_id))
|
||||||
|
session.commit()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 == []
|
||||||
|
|||||||
@@ -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")}
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))]
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user