Compare commits

...

11 Commits

Author SHA256 Message Date
f1b5d442bd docs: Ledger Ausbaustufe 5 Tasks 1-8 + Plan-Haekchen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:02:29 +02:00
a192866cc4 chore: Version 0.6.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:59:58 +02:00
033c41f449 feat: Szenario-GUI mit Ende-Modifikator und Einmalzahlungen je Szenario
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:59:42 +02:00
13795fce25 feat: API fuer Szenario-Ende-Modifikator und szenario-eigene Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:52:15 +02:00
5519037688 feat: Engine+Schema fuer Szenario-Ende und szenario-eigene Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:46:52 +02:00
aa1884c31e feat: Inline-Bearbeiten fuer Fixposten, Einmalposten, Kredite und Szenario-Kopf
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:42:04 +02:00
cbcf2e0439 feat: Start/Ende fuer Fixposten in der GUI + Datumsvalidierung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:35:32 +02:00
805cb4d852 feat: Betragseingabe mit Komma (data-type=amount in json-form)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:30:58 +02:00
fe0e1b5e93 feat: deutsche Betragsanzeige (eur-Filter) und deutsche GUI-Labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:23:26 +02:00
bd9df8ab8b docs: Implementierungsplan Ausbaustufe 5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:13:21 +02:00
8e425840e8 docs: Design-Spec Ausbaustufe 5 (deutsche Formate, Posten-Bearbeitung, Szenario-Ende)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:45:39 +02:00
24 changed files with 2041 additions and 58 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
# Design — Ausbaustufe 5: Deutsche Formate, Posten-Bearbeitung, Szenario-Ende (v0.6.0)
> Status: vom Nutzer freigegeben (Chat 2026-07-20). Umsetzungsplan folgt unter
> `docs/superpowers/plans/`. Datenschutz-Regel aus `CLAUDE.md` gilt: konkrete
> Empfängernamen/Beträge des Demo-Szenarios stehen nur im Chat und in der
> Live-Datenbank, nicht in diesem Dokument, nicht in Commits, nicht im Ledger.
## Ziel
Drei Nutzeranforderungen an die Web-GUI des Finanzberatungs-Tools:
1. **Deutsche Formate** — Beträge werden deutsch angezeigt (`1.234,56 €`)
und deutsch eingegeben (Komma-Dezimaltrenner); englische GUI-Begriffe
(`monthly`, `annuity`, `percent`, …) werden übersetzt.
2. **Wiederkehrende Posten** — Felder `Start`/`Ende` in der GUI sichtbar und
pflegbar (Modell/API/Engine können das bereits); jeder Posten bekommt
einen `Bearbeiten`-Button. Inline-Bearbeitung zusätzlich für
Einmalposten, Kredite und den Szenario-Kopf (Nutzerentscheidung).
3. **Szenarien** — Modifikator-Art `Ende` (Posten endet innerhalb des
Szenarios an einem Datum, ohne den Posten selbst zu ändern) und
szenario-eigene Einmalzahlungen. Demo-Szenario „Best Case" wird nach dem
Release live angelegt und durchgerechnet.
## Nutzerentscheidungen (Chat 2026-07-20)
- Szenario-Ende als **neue Modifikator-Art `ende`** mit Datumsfeld (nicht
als Gültigkeitszeitraum aller Modifikatoren).
- Einmalzahlung im Demo-Szenario ist **szenario-spezifisch** → neues Feature
„Szenario-Einmalzahlungen".
- Formate: **Zahlen + deutsche Begriffe** (API-Werte bleiben englisch).
- Bearbeiten-Buttons für: wiederkehrende Posten, Einmalposten, Kredite,
Szenario-Kopf.
## Teil 1 — Deutsche Formate
**Anzeige.** Neuer Jinja-Filter `eur` in `app/routers/gui.py`
(`templates.env.filters["eur"]`), reine `Decimal`-Formatierung ohne
`locale`-Modul (Container-Locale unzuverlässig): Vorzeichen, Tausenderpunkt,
Komma, zwei Nachkommastellen. Ersetzt alle `'%.2f'|format(...)`-Stellen in
`index.html`, `salden.html`, `transactions.html`, `planning.html`,
`_preview_table.html`. Prozentwerte (Kredit-Zins) analog mit Komma.
Der per JavaScript nachgeladene Tilgungsplan (`planning.html`,
`loadLoanSchedule`) formatiert mit
`toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2})`.
**Eingabe.** Die json-form-Extension in `base.html` lernt
`data-type="amount"`: Whitespace/€ entfernen; enthält der Wert ein Komma →
Tausenderpunkte entfernen, Komma→Punkt; ohne Komma bleibt der Wert
unverändert (Punkt-Eingaben funktionieren weiter). Alle
Betrags-`<input>`-Felder (Fixposten, Einmalposten, Kredit, Modifikator-Wert,
Szenario-Einmalzahlung) bekommen `data-type="amount"` und deutsche
Platzhalter (`-49,99`). Versteckte Felder der Vorschlags-Übernahme bleiben
ohne `data-type="amount"` (Server liefert Punktformat). Die JSON-API bleibt
strikt Punkt-Dezimal — Tests, Skripte und Grafana sind nicht betroffen.
**Begriffe.** Zentrales Mapping als Jinja-Global `de_label` (Definition in
`gui.py`): `monthly→monatlich`, `quarterly→vierteljährlich`,
`yearly→jährlich`, `annuity→Annuität`, `bullet→endfällig`,
`percent→Prozent`, `absolute→Absolut`, `remove→Entfällt`, `ende→Ende`.
Dropdown-`<option>`-Texte und Tabellenzellen zeigen deutsche Labels;
`value`-Attribute, API-Literale und DB-Werte bleiben englisch (keine
Migration, keine API-Änderung). Unbekannte Werte fallen auf den Rohwert
zurück.
## Teil 2 — Posten-Bearbeitung + Start/Ende
`RecurringItem.start_date/end_date` existieren in Modell, API
(`RecurringIn`/`RecurringPatch`) und Engine (`recurrence.occurrences`
klammert das Fenster bereits). Es fehlt nur GUI:
- Tabelle „Wiederkehrende Posten": neue Spalten **Start** und **Ende**
(TT.MM.JJJJ, leer = „–").
- Anlege-Formular: zwei optionale `<input type="date">`-Felder.
- **Inline-Bearbeiten** je Zeile (Muster für alle vier Bereiche):
`Bearbeiten`-Button blendet eine vorbefüllte Formularzeile ein
(Anzeigezeile wird versteckt, nicht entfernt — UX-Regel aus `CLAUDE.md`
bleibt gewahrt: Bedienelemente sichtbar/disabled, Fehler via bestehendem
`htmx:responseError`-Handler), `Speichern` sendet `PATCH` über die
json-form-Extension, `Abbrechen` klappt zurück. Leeres Datumsfeld wird als
`null` gesendet (Extension tut das bereits) und löscht den Wert.
- Bereiche: `PATCH /api/recurring/{id}` (alle Felder),
`PATCH /api/planned/{id}` (Name, Betrag, Fälligkeit, Kategorie),
`PATCH /api/loans/{id}` (alle Felder),
`PATCH /api/scenarios/{id}` (Name, Beschreibung, include-Flags).
- Neue Validierung in `RecurringIn`/`RecurringPatch` (Pydantic
model_validator): `end_date ≥ start_date`, wenn beide gesetzt (beim PATCH
gegen den resultierenden Zustand geprüft), sonst 422 mit deutscher
Meldung.
## Teil 3 — Szenarien: Art `ende` + Szenario-Einmalzahlungen
Eine Alembic-Migration (`scenario_ende`) mit zwei Änderungen:
1. `scenario_modifiers.end_date``Date`, nullable.
2. Neue Tabelle `scenario_planned_items`: `id`, `scenario_id`
(FK `scenarios.id`), `name` (String 200), `amount` (`MONEY`), `due`
(`Date`).
**Modifikator-Art `ende`.**
- API: `ModifierIn.kind` um Literal `ende` erweitert; neues optionales Feld
`end_date: date | None`. Validator: `ende` erfordert `end_date`; andere
Arten ignorieren es (wird genullt). `ModifierOut` liefert `end_date` mit.
- Engine: `PlainModifier` bekommt `end_date: date | None = None`.
`build_cashflows` bestimmt vor `occurrences` je Posten das effektive Ende:
Minimum aus eigenem `end_date` und allen treffenden `ende`-Modifikatoren
(Ziel `recurring` oder `category` — bei Kategorie enden alle Posten der
Kategorie). `_modified` behandelt weiterhin nur die Betrags-Arten und
überspringt `ende`.
- GUI (`planning.html`): Art-Dropdown bekommt „Ende"; ein Datumsfeld wird
nur bei dieser Art aktiviert (disabled-Umschalter analog
`onModTargetTypeChange`), das Wert-Feld ist bei `ende` disabled.
Modifikator-Tabelle zeigt das Datum.
**Szenario-Einmalzahlungen.**
- Endpunkte in `routers/scenarios.py`:
`GET/POST /api/scenarios/{id}/planned` (201, Name/Betrag/Datum),
`DELETE /api/scenarios/{id}/planned/{item_id}` (404 bei fremder
scenario_id, Muster `delete_modifier`).
- `run_projection` lädt `scenario_planned_items` des Szenarios und hängt sie
als `PlainPlanned` an — **unabhängig** von `include_planned` (das steuert
nur globale Einmalposten; szenario-eigene Zahlungen sind bewusst diesem
Szenario zugeordnet).
- `delete_scenario` löscht zusätzlich `scenario_planned_items` (kein
DB-Cascade vorhanden, Muster der bestehenden Deletes).
- GUI: je Szenario ein `<details>` „Einmalzahlungen in diesem Szenario"
(Tabelle Name/Betrag/Datum + Löschen, Anlege-Formular).
## Teil 4 — Demo „Best Case", Release, Tests
**Demo (live, nach Redeploy):** Szenario „Best Case" anlegen; für die beiden
bestehenden Miet-Fixposten (falls nicht vorhanden: vorher anlegen bzw. aus
Vorschlägen übernehmen) je einen `ende`-Modifikator mit den im Chat
genannten Terminen setzen; szenario-eigene Einmalzahlung mit dem im Chat
genannten Betrag/Datum anlegen; Projektion durchrechnen. Anschließend
Schritt-für-Schritt-Erklärung der Eingabe im Chat. `hilfe.html` bekommt
einen kurzen Abschnitt zu Szenario-Varianten (generisch, ohne echte Daten).
**Release:** `finance/VERSION``0.6.0` vor `./create_pod_finance.sh`
(Migration läuft im Entrypoint via `alembic upgrade head`).
**Tests (TDD, Suite bleibt komplett grün):**
- `eur`-Filter: Rundung, Tausenderpunkte, negative Werte, `Decimal`-Input.
- Engine: `ende`-Modifikator (Ziel Posten/Kategorie, Zusammenspiel mit
eigenem `end_date`, Minimum-Regel), Szenario-Einmalzahlung im Fenster.
- API: Modifier-Validierung (`ende` ohne Datum → 422), CRUD
Szenario-Einmalzahlungen, Cleanup bei `delete_scenario`,
`end ≥ start`-Validierung bei Fixposten.
- GUI-Tests: deutsche Betragsformatierung in gerenderten Seiten, neue
Spalten/Formulare vorhanden, deutsche Labels statt englischer Rohwerte.
- Migration: Upgrade-Pfad in Wegwerf-DB (Muster Konto-Anker-Task).
**Außerhalb des Scopes:** Grafana-Dashboards (formatiert selbst),
PDF/CSV-Parser (parsen deutsche Formate bereits), Übersetzung von
API-Fehlertexten Dritter, Mehrsprachigkeit.

View File

@@ -1 +1 @@
0.5.0
0.6.0

View File

@@ -0,0 +1,37 @@
"""szenario ende + scenario_planned_items
Revision ID: c4d7e2a91b53
Revises: 1ef6a356f028
Create Date: 2026-07-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'c4d7e2a91b53'
down_revision: Union[str, Sequence[str], None] = '1ef6a356f028'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('scenario_modifiers', sa.Column('end_date', sa.Date(), nullable=True))
op.create_table(
'scenario_planned_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('due', sa.Date(), nullable=False),
)
op.create_index('ix_scenario_planned_items_scenario_id',
'scenario_planned_items', ['scenario_id'])
def downgrade() -> None:
op.drop_index('ix_scenario_planned_items_scenario_id', 'scenario_planned_items')
op.drop_table('scenario_planned_items')
op.drop_column('scenario_modifiers', 'end_date')

View File

@@ -34,6 +34,7 @@ class PlainModifier:
target_id: int
kind: str
value: Decimal
end_date: date | None = None
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
@@ -55,6 +56,19 @@ def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None
return amount
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
end = r.end_date
for m in modifiers:
if m.kind != "ende" or m.end_date is None:
continue
hit = ((m.target_type == "category" and r.category_id == m.target_id)
or (m.target_type == "recurring" and r.id == m.target_id))
if hit:
end = m.end_date if end is None else min(end, m.end_date)
return end
def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned],
loan_schedules: list[list[Installment]],
loan_payouts: list[tuple[date, Decimal]],
@@ -66,7 +80,7 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
if amount is None:
continue
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
r.start_date, r.end_date):
r.start_date, _effective_end(r, modifiers)):
flows.append((d, amount))
for p in planned:
amount = _modified(p.amount, p.category_id, None, modifiers)

30
finance/app/formats.py Normal file
View File

@@ -0,0 +1,30 @@
"""Deutsche Anzeige-Formate fuer die GUI (Ausbaustufe 5).
Bewusst ohne locale-Modul: Container-Locales sind nicht garantiert
installiert, und die Ausgabe hinge sonst vom Host ab. Reine
Decimal-Formatierung; die JSON-API bleibt Punkt-Dezimal.
"""
from decimal import Decimal, ROUND_HALF_UP
DE_LABELS = {
"monthly": "monatlich",
"quarterly": "vierteljährlich",
"yearly": "jährlich",
"annuity": "Annuität",
"bullet": "endfällig",
"percent": "Prozent",
"absolute": "Absolut",
"remove": "Entfällt",
"ende": "Ende",
}
def eur(value) -> str:
"""Decimal("-2474.5") -> "-2.474,50" (ohne Euro-Zeichen, das setzt das Template)."""
d = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
s = f"{d:,.2f}" # US-Trenner: "-2,474.50"
return s.replace(",", "\x00").replace(".", ",").replace("\x00", ".")
def de_label(value: str) -> str:
return DE_LABELS.get(value, value)

View File

@@ -123,6 +123,21 @@ class ScenarioModifier(Base):
target_id: Mapped[int] = mapped_column(Integer)
kind: Mapped[str] = mapped_column(String(20))
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
# Nur fuer kind="ende" gesetzt: Datum, an dem der Ziel-Posten innerhalb
# dieses Szenarios endet (Ausbaustufe 5).
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
class ScenarioPlannedItem(Base):
"""Einmalzahlung, die NUR in einem bestimmten Szenario zaehlt -
unabhaengig von Scenario.include_planned (das steuert nur die globalen
PlannedItems). Ausbaustufe 5."""
__tablename__ = "scenario_planned_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)
due: Mapped[date] = mapped_column(Date)
class ProjectionPoint(Base):

View File

@@ -10,9 +10,11 @@ from app.auth import COOKIE, session_valid
from app.db import get_session
from app.engine.loans import add_months
from app.engine.recurrence import occurrences
from app.formats import de_label, eur
from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier, Transaction)
ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, Transaction)
from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import
from app.routers.planning import list_loans as _list_loans
@@ -28,6 +30,8 @@ PAGE_SIZE = 50
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["app_version"] = get_version()
templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label
router = APIRouter()
@@ -261,10 +265,15 @@ def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]
modifiers = session.execute(
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
).scalars().all()
planned_items = session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == sc.id)
.order_by(ScenarioPlannedItem.due)).scalars().all()
rows.append({
"scenario": sc,
"assigned_loan_ids": assigned_loan_ids,
"modifiers": modifiers,
"planned_items": planned_items,
"result": session.get(ProjectionResult, sc.id),
})
return rows
@@ -293,5 +302,5 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
"scenario_rows": _scenario_rows(session, scenarios, loans),
"rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"],
"modifier_kinds": ["percent", "absolute", "remove"],
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
})

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
@@ -28,6 +28,13 @@ class RecurringIn(BaseModel):
end_date: date | None = None
category_id: int | None = None
@model_validator(mode="after")
def _ende_nicht_vor_start(self):
if (self.start_date is not None and self.end_date is not None
and self.end_date < self.start_date):
raise ValueError("Ende darf nicht vor Start liegen")
return self
class RecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True)
@@ -90,6 +97,10 @@ def patch_recurring(item_id: int, data: RecurringPatch,
_check_category(session, fields["category_id"])
for key, value in fields.items():
setattr(item, key, value)
if (item.start_date is not None and item.end_date is not None
and item.end_date < item.start_date):
session.rollback()
raise HTTPException(422, "Ende darf nicht vor Start liegen")
session.commit()
session.refresh(item)
return RecurringOut.model_validate(item)

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
@@ -11,7 +11,8 @@ from app.auth import require_auth
from app.config import get_settings
from app.db import get_session
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
Scenario, ScenarioLoan, ScenarioModifier)
Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
from app.services.projection_service import run_projection
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
@@ -36,12 +37,26 @@ class ScenarioPatch(BaseModel):
include_recurring: bool | None = None
include_planned: bool | None = None
@field_validator("description")
@classmethod
def _leere_beschreibung(cls, v):
return "" if v is None else v
class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"]
target_id: int
kind: Literal["percent", "absolute", "remove"]
kind: Literal["percent", "absolute", "remove", "ende"]
value: Decimal = Decimal("0")
end_date: date | None = None
@model_validator(mode="after")
def _ende_braucht_datum(self):
if self.kind == "ende" and self.end_date is None:
raise ValueError("Art »Ende« erfordert ein Datum")
if self.kind != "ende":
self.end_date = None
return self
class ModifierOut(ModifierIn):
@@ -117,6 +132,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
ScenarioLoan.scenario_id == scenario_id))
session.execute(delete(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario_id))
session.execute(delete(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario_id))
session.delete(scenario)
session.commit()
@@ -164,6 +181,50 @@ def delete_modifier(scenario_id: int, mod_id: int,
session.commit()
class ScenarioPlannedIn(BaseModel):
name: str
amount: Decimal
due: date
class ScenarioPlannedOut(ScenarioPlannedIn):
model_config = ConfigDict(from_attributes=True)
id: int
scenario_id: int
@router.get("/{scenario_id}/planned", response_model=list[ScenarioPlannedOut])
def list_scenario_planned(scenario_id: int, session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
return [ScenarioPlannedOut.model_validate(p) for p in session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == scenario_id)
.order_by(ScenarioPlannedItem.due)).scalars()]
@router.post("/{scenario_id}/planned", response_model=ScenarioPlannedOut,
status_code=201)
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return ScenarioPlannedOut.model_validate(item)
@router.delete("/{scenario_id}/planned/{item_id}", status_code=204)
def delete_scenario_planned(scenario_id: int, item_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
item = session.get(ScenarioPlannedItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Einmalzahlung nicht gefunden")
session.delete(item)
session.commit()
@router.post("/{scenario_id}/project", response_model=ProjectionOut)
def project_scenario(scenario_id: int, horizon_days: int | None = None,
start_date: date | None = None,

View File

@@ -12,7 +12,8 @@ from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
build_cashflows)
from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
ProjectionResult, RecurringItem, Scenario,
ScenarioLoan, ScenarioModifier)
ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
from app.services.balances import total_balance
@@ -28,6 +29,11 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int,
if scenario.include_planned:
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
for p in session.execute(select(PlannedItem)).scalars()]
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
# include_planned steuert nur die globalen PlannedItems.
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
for p in session.execute(select(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
loans = session.execute(
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
@@ -35,7 +41,8 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int,
l.term_months, l.payout_date, l.repayment_type)
for l in loans]
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind, Decimal(m.value))
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
Decimal(m.value), m.end_date)
for m in session.execute(select(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario.id)).scalars()]
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,

View File

@@ -8,7 +8,7 @@
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
<td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</td>
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }} €</td>
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ tx.amount|eur }} €</td>
<td>{% if tx.is_duplicate %}<span class="badge badge-warning">Duplikat</span>{% endif %}</td>
</tr>
{% else %}
@@ -25,7 +25,7 @@
{% else %}
<span class="badge badge-error">Saldo-Differenz bitte prüfen</span>
{% endif %}
· Eröffnungssaldo: {{ '%.2f'|format(statement.opening_balance) if statement.opening_balance is not none else '' }} €
· Endsaldo: {{ '%.2f'|format(statement.closing_balance) if statement.closing_balance is not none else '' }} €
· Eröffnungssaldo: {{ statement.opening_balance|eur if statement.opening_balance is not none else '' }} €
· Endsaldo: {{ statement.closing_balance|eur if statement.closing_balance is not none else '' }} €
· Duplikate: {{ duplicates }}
</p>

View File

@@ -50,6 +50,14 @@
out[k] = null;
} else if (kind === 'int') {
out[k] = parseInt(v, 10);
} else if (kind === 'amount') {
// Deutsche Betragseingabe: "1.234,56" -> "1234.56". Ohne Komma
// bleibt der Wert unveraendert (Punkt-Eingaben funktionieren weiter).
var s = String(v).replace(/[\s€]/g, '');
if (s.indexOf(',') !== -1) {
s = s.replace(/\./g, '').replace(',', '.');
}
out[k] = s;
} else {
out[k] = v;
}

View File

@@ -70,6 +70,11 @@
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
Varianten wie „Best Case“ entstehen über Modifikatoren: Art „Ende“ lässt
einen wiederkehrenden Posten (z. B. eine auslaufende Miete) innerhalb des
Szenarios zu einem Datum enden, ohne den Posten selbst zu ändern; „Prozent“/
„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz aus. Szenario-eigene
Einmalzahlungen (z. B. ein erwarteter Zufluss) zählen nur in ihrem Szenario.
</li>
<li>
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach

View File

@@ -11,7 +11,7 @@
{% endif %}
<div class="total-balance {{ 'neg' if total < 0 else '' }}">
Gesamtsaldo: {{ '%.2f'|format(total) }} €
Gesamtsaldo: {{ total|eur }} €
</div>
<table>
@@ -26,10 +26,10 @@
<tr>
<td class="account">{{ account.name }}</td>
<td>{{ account.bank }}</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
<td>
{% if account.anchor_date is not none and account.anchor_balance is not none %}
Anker: {{ '%.2f'|format(account.anchor_balance) }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
Anker: {{ account.anchor_balance|eur }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
{% else %}
kein Anker
{% endif %}
@@ -46,8 +46,8 @@
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<input type="date" name="anchor_date"
value="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}" required>
<input type="text" name="anchor_balance" placeholder="500.00"
value="{{ account.anchor_balance if account.anchor_balance is not none else '' }}" required>
<input type="text" name="anchor_balance" data-type="amount" placeholder="500,00"
value="{{ account.anchor_balance|eur if account.anchor_balance is not none else '' }}" required>
<button type="submit">Anker setzen</button>
</form>
</td>
@@ -68,7 +68,7 @@
<tr>
<td>{{ item.date.strftime('%d.%m.%Y') }}</td>
<td>{{ item.name }}</td>
<td class="{{ 'neg' if item.amount < 0 else '' }}">{{ '%.2f'|format(item.amount) }} €</td>
<td class="{{ 'neg' if item.amount < 0 else '' }}">{{ item.amount|eur }} €</td>
</tr>
{% else %}
<tr><td colspan="3">Keine anstehenden Posten.</td></tr>

View File

@@ -7,17 +7,20 @@
<h2>Wiederkehrende Posten</h2>
<table>
<thead>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Kategorie</th><th></th></tr>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th>Ende</th><th>Kategorie</th><th></th></tr>
</thead>
<tbody>
{% for r in recurring %}
<tr>
<tr id="rec-row-{{ r.id }}">
<td>{{ r.name }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ '%.2f'|format(r.amount) }} €</td>
<td>{{ r.rhythm }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td>
<td>{{ r.rhythm|de_label }}</td>
<td>{{ r.due_day }}</td>
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_date else '' }}</td>
<td>{{ r.end_date.strftime('%d.%m.%Y') if r.end_date else '' }}</td>
<td>{{ category_names.get(r.category_id, '') }}</td>
<td>
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none"
hx-confirm="„{{ r.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -25,8 +28,33 @@
</form>
</td>
</tr>
<tr id="rec-edit-{{ r.id }}" hidden>
<td colspan="8">
<form hx-ext="json-form" hx-patch="/api/recurring/{{ r.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ r.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ r.amount|eur }}" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == r.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="{{ r.due_day }}" required></label>
<label>Start <input type="date" name="start_date" value="{{ r.start_date.isoformat() if r.start_date else '' }}"></label>
<label>Ende <input type="date" name="end_date" value="{{ r.end_date.isoformat() if r.end_date else '' }}"></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == r.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6">Noch keine wiederkehrenden Posten.</td></tr>
<tr><td colspan="8">Noch keine wiederkehrenden Posten.</td></tr>
{% endfor %}
</tbody>
</table>
@@ -36,13 +64,15 @@
<form hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" placeholder="-49.99" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-49,99" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh }}</option>{% endfor %}
{% 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></label>
<label>Start (optional) <input type="date" name="start_date"></label>
<label>Ende (optional) <input type="date" name="end_date"></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
@@ -64,8 +94,8 @@
{% for s in suggestions %}
<tr>
<td>{{ s.name }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ '%.2f'|format(s.amount) }} €</td>
<td>{{ s.rhythm }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ s.amount|eur }} €</td>
<td>{{ s.rhythm|de_label }}</td>
<td>{{ s.due_day }}</td>
<td>
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
@@ -96,12 +126,13 @@
</thead>
<tbody>
{% for p in planned %}
<tr>
<tr id="pln-row-{{ p.id }}">
<td>{{ p.name }}</td>
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ '%.2f'|format(p.amount) }} €</td>
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ p.amount|eur }} €</td>
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
<td>{{ category_names.get(p.category_id, '') }}</td>
<td>
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/planned/{{ p.id }}" hx-swap="none"
hx-confirm="„{{ p.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -109,6 +140,24 @@
</form>
</td>
</tr>
<tr id="pln-edit-{{ p.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/planned/{{ p.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ p.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ p.amount|eur }}" required></label>
<label>Fällig am <input type="date" name="due" value="{{ p.due.isoformat() }}" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == p.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5">Noch keine Einmalposten.</td></tr>
{% endfor %}
@@ -120,7 +169,7 @@
<form hx-ext="json-form" hx-post="/api/planned" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" placeholder="-2000.00" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-2000,00" required></label>
<label>Fällig am <input type="date" name="due" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
@@ -141,14 +190,15 @@
</thead>
<tbody>
{% for l in loans %}
<tr>
<tr id="loan-row-{{ l.id }}">
<td>{{ l.name }}</td>
<td>{{ '%.2f'|format(l.principal) }} €</td>
<td>{{ '%.2f'|format(l.annual_rate_pct) }} %</td>
<td>{{ l.principal|eur }} €</td>
<td>{{ l.annual_rate_pct|eur }} %</td>
<td>{{ l.term_months }} Monate</td>
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
<td>{{ l.repayment_type }}</td>
<td>{{ l.repayment_type|de_label }}</td>
<td>
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/loans/{{ l.id }}" hx-swap="none"
hx-confirm="„{{ l.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -156,6 +206,27 @@
</form>
</td>
</tr>
<!-- Hinweis: eine Kredit-Aenderung veraendert den Tilgungsplan; danach
muessen betroffene Szenarien neu durchgerechnet werden. -->
<tr id="loan-edit-{{ l.id }}" hidden>
<td colspan="7">
<form hx-ext="json-form" hx-patch="/api/loans/{{ l.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<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>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>Auszahlungsdatum <input type="date" name="payout_date" value="{{ l.payout_date.isoformat() }}" required></label>
<label>Tilgungsart
<select name="repayment_type" data-type="str">
{% for rt in repayment_types %}<option value="{{ rt }}" {% if rt == l.repayment_type %}selected{% endif %}>{{ rt|de_label }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
<tr>
<td colspan="7">
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
@@ -175,13 +246,13 @@
<form hx-ext="json-form" hx-post="/api/loans" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Darlehenssumme <input type="text" name="principal" placeholder="10000.00" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" placeholder="4.5" 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>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" required></label>
<label>Auszahlungsdatum <input type="date" name="payout_date" required></label>
<label>Tilgungsart
<select name="repayment_type" data-type="str">
{% for rt in repayment_types %}<option value="{{ rt }}">{{ rt }}</option>{% endfor %}
{% for rt in repayment_types %}<option value="{{ rt }}">{{ rt|de_label }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
@@ -195,11 +266,23 @@
{% set sc = row.scenario %}
<fieldset>
<legend>{{ sc.name }}</legend>
<p>
<p id="sc-row-{{ sc.id }}">
{{ sc.description }}
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
</p>
<div id="sc-edit-{{ sc.id }}" hidden>
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sc.name }}" required></label>
<label>Beschreibung <input type="text" name="description" value="{{ sc.description }}"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" {% if sc.include_recurring %}checked{% endif %}> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" {% if sc.include_planned %}checked{% endif %}> Einmalposten einschließen</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, false)">Abbrechen</button>
</form>
</div>
<details>
<summary>Kredite zuordnen</summary>
@@ -227,8 +310,11 @@
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
</td>
<td>{{ m.kind }}</td>
<td>{{ '%.2f'|format(m.value) }}</td>
<td>{{ m.kind|de_label }}</td>
<td>
{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '' }}
{% else %}{{ m.value|eur }}{% endif %}
</td>
<td>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -265,15 +351,49 @@
</label>
</span>
<label>Art
<select name="kind" data-type="str">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k }}</option>{% endfor %}
<select name="kind" data-type="str" onchange="onModKindChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
</select>
</label>
<label>Wert <input type="text" name="value" value="0"></label>
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
<label>Endet am <input type="date" name="end_date" disabled></label>
<button type="submit">Modifikator hinzufügen</button>
</form>
</details>
<details>
<summary>Einmalzahlungen in diesem Szenario</summary>
<table>
<thead><tr><th>Name</th><th>Betrag</th><th>Fällig am</th><th></th></tr></thead>
<tbody>
{% for sp in row.planned_items %}
<tr>
<td>{{ sp.name }}</td>
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
<td>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4">Keine szenario-eigenen Einmalzahlungen.</td></tr>
{% endfor %}
</tbody>
</table>
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" required></label>
<label>Fällig am <input type="date" name="due" required></label>
<button type="submit">Einmalzahlung hinzufügen</button>
</form>
<p class="hint">Zählt nur in diesem Szenario — unabhängig von „Einmalposten einschließen“.</p>
</details>
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
@@ -281,7 +401,7 @@
{% if row.result %}
<p>
Tiefpunkt: {{ '%.2f'|format(row.result.low_point_balance) }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
@@ -311,6 +431,12 @@
// json-form-Extension ist zentral in base.html registriert (eine
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
function toggleEdit(prefix, id, editing) {
// UX-Regel: Zeilen werden versteckt (hidden), nie aus dem DOM entfernt.
document.getElementById(prefix + '-row-' + id).hidden = editing;
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
}
function onModTargetTypeChange(select) {
var form = select.closest('form');
var catSpan = form.querySelector('.mod-target-category');
@@ -324,6 +450,13 @@
recSelect.disabled = isCategory;
}
function onModKindChange(select) {
var form = select.closest('form');
var isEnde = select.value === 'ende';
form.querySelector('[name="end_date"]').disabled = !isEnde;
form.querySelector('[name="value"]').disabled = isEnde;
}
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function (resp) {
@@ -343,6 +476,9 @@
var parts = iso.split('-');
return parts[2] + '.' + parts[1] + '.' + parts[0];
}
function fmtEur(n) {
return Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function loadLoanSchedule(loanId) {
if (loadedSchedules[loanId]) return;
loadedSchedules[loanId] = true;
@@ -353,10 +489,10 @@
+ '<th>Tilgung</th><th>Restschuld</th></tr></thead><tbody>';
rows.forEach(function (r) {
html += '<tr><td>' + formatIsoDate(r.due) + '</td>'
+ '<td>' + Number(r.payment).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.interest).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.principal).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.remaining).toFixed(2) + ' €</td></tr>';
+ '<td>' + fmtEur(r.payment) + ' €</td>'
+ '<td>' + fmtEur(r.interest) + ' €</td>'
+ '<td>' + fmtEur(r.principal) + ' €</td>'
+ '<td>' + fmtEur(r.remaining) + ' €</td></tr>';
});
html += '</tbody></table>';
document.getElementById('schedule-' + loanId).innerHTML = html;

View File

@@ -17,14 +17,14 @@
{% for account, balance in balances %}
<tr>
<td class="account">{{ account.name }}</td>
<td class="amount {{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
<td class="amount {{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
</tr>
{% else %}
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
{% endfor %}
<tr class="total-row">
<td><strong>Gesamt</strong></td>
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ '%.2f'|format(total) }} €</strong></td>
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ total|eur }} €</strong></td>
</tr>
</tbody>
</table>
@@ -48,9 +48,9 @@
<tr data-month="{{ row['month'].isoformat() }}">
<td>{{ row['month'].strftime('%m.%Y') }}</td>
{% for account, value in row['values'] %}
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ '%.2f'|format(value) }} €</td>
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ value|eur }} €</td>
{% endfor %}
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ '%.2f'|format(row['total']) }} €</td>
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ row['total']|eur }} €</td>
</tr>
{% else %}
<tr><td colspan="{{ accounts|length + 2 }}">Keine bestätigten Buchungen vorhanden.</td></tr>

View File

@@ -53,7 +53,7 @@
<td class="account">{{ account_names.get(tx.account_id, tx.account_id) }}</td>
<td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</td>
<td class="amount {{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }}</td>
<td class="amount {{ 'neg' if tx.amount < 0 else '' }}">{{ tx.amount|eur }}</td>
<td>
<select name="category_id" data-type="int" hx-ext="json-form"
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none">

View File

@@ -186,3 +186,46 @@ def test_patch_transaction_category(client):
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
json={"category_id": 9999})
assert r.status_code == 404
def test_scenario_patch_beschreibung_leeren(client):
sc = client.post("/api/scenarios", headers=H, json={"name": "DescNull", "description": "x"}).json()
r = client.patch(f"/api/scenarios/{sc['id']}", headers=H, json={"description": None})
assert r.status_code == 200 and r.json()["description"] == ""
def test_modifier_ende_validierung(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "EndeVal"}).json()
# ende ohne Datum -> 422
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende"})
assert r.status_code == 422
# ende mit Datum -> 201, end_date in der Antwort
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende",
"end_date": "2026-08-31"})
assert r.status_code == 201
assert r.json()["end_date"] == "2026-08-31"
# andere Arten nullen end_date
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove",
"end_date": "2026-08-31"})
assert r.status_code == 201 and r.json()["end_date"] is None
def test_scenario_planned_crud_und_cleanup(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-CRUD"}).json()
r = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Sonderzahlung", "amount": "5000.00", "due": "2026-07-30"})
assert r.status_code == 201
item = r.json()
assert item["scenario_id"] == sc["id"] and item["amount"] == "5000.00"
assert client.get(f"/api/scenarios/{sc['id']}/planned", headers=H).json()[0]["name"] == "Sonderzahlung"
# DELETE mit fremder scenario_id -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "SPI-Other"}).json()
assert client.delete(f"/api/scenarios/{other['id']}/planned/{item['id']}",
headers=H).status_code == 404
# Szenario loeschen raeumt Einmalzahlungen mit ab (kein FK-Fehler):
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
from app.models.tables import ScenarioPlannedItem
assert db.query(ScenarioPlannedItem).count() == 0

View File

@@ -0,0 +1,43 @@
from decimal import Decimal
from app.formats import DE_LABELS, de_label, eur
def test_eur_basisformat():
assert eur(Decimal("2474.00")) == "2.474,00"
def test_eur_negativ_mit_mehreren_tausenderpunkten():
assert eur(Decimal("-1234567.5")) == "-1.234.567,50"
def test_eur_null_und_kleinbetraege():
assert eur(Decimal("0")) == "0,00"
assert eur(Decimal("-0.5")) == "-0,50"
assert eur(Decimal("999.99")) == "999,99"
def test_eur_rundung_half_up():
assert eur(Decimal("1.005")) == "1,01"
assert eur(Decimal("-1.005")) == "-1,01"
def test_eur_akzeptiert_strings_und_int():
# Templates reichen teils DB-Werte als str/int durch (z.B. Suggestions).
assert eur("1234.5") == "1.234,50"
assert eur(7) == "7,00"
def test_de_label_bekannte_und_unbekannte_werte():
assert de_label("monthly") == "monatlich"
assert de_label("quarterly") == "vierteljährlich"
assert de_label("yearly") == "jährlich"
assert de_label("annuity") == "Annuität"
assert de_label("bullet") == "endfällig"
assert de_label("percent") == "Prozent"
assert de_label("absolute") == "Absolut"
assert de_label("remove") == "Entfällt"
assert de_label("ende") == "Ende"
assert de_label("weirdvalue") == "weirdvalue"
assert set(DE_LABELS) == {"monthly", "quarterly", "yearly", "annuity",
"bullet", "percent", "absolute", "remove", "ende"}

View File

@@ -1,7 +1,9 @@
from datetime import date, timedelta
from decimal import Decimal
from app.models.tables import Account
from app.formats import eur
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem)
H = {"Authorization": "Bearer test-key"}
@@ -34,7 +36,7 @@ def test_index_shows_account_anchor(client, db):
r = client.get("/")
assert r.status_code == 200
assert "Anker: 321.00" in r.text
assert "Anker: 321,00" in r.text
assert "30.06.2026" in r.text
assert "kein Anker" in r.text
# Inline-Pflegeformular fuer den Anker muss je Konto vorhanden sein.
@@ -42,6 +44,45 @@ def test_index_shows_account_anchor(client, db):
assert 'name="anchor_balance"' in r.text
def test_gui_zeigt_deutsche_betragsformate(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
acc = Account(bank="dkb", iban="DE-FMT-1", name="Formatkonto", type="giro")
db.add(acc)
db.flush()
acc.anchor_date = date(2026, 6, 30)
acc.anchor_balance = Decimal("12345.60")
db.commit()
r = client.get("/")
assert "12.345,60" in r.text
assert "12345.60" not in r.text
def test_planung_zeigt_deutsche_labels(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Miete-Label-Test", amount=Decimal("-600.00"),
rhythm="monthly", due_day=1))
db.commit()
r = client.get("/planung")
assert "monatlich" in r.text
assert "-600,00" in r.text
def test_betragsfelder_haben_amount_typ_und_deutsche_platzhalter(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
planung = client.get("/planung").text
# Fixposten-, Einmalposten-, Kredit-Formulare senden deutsche Eingaben:
assert planung.count('data-type="amount"') >= 4
assert '-49,99' in planung and '-2000,00' in planung
assert '-49.99' not in planung and '-2000.00' not in planung
# Anker-Formular wird nur je Konto gerendert - ohne Konto in der DB gaebe
# es keine Zeile mit dem Anker-Input (Template-Zweig "Keine Konten
# angelegt"), daher hier ein Konto anlegen wie in den Nachbartests.
db.add(Account(bank="dkb", iban="DE-AMT-1", name="Amount-Test", type="giro"))
db.commit()
index = client.get("/").text
assert 'placeholder="500,00"' in index
def test_import_page_has_dropzone_and_list(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import")
@@ -185,6 +226,18 @@ def test_planning_page_has_all_sections(client):
assert heading in r.text
def test_planung_fixposten_zeigt_start_und_ende(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Befristet-GUI", amount=Decimal("-5.00"),
rhythm="monthly", due_day=1,
start_date=date(2026, 1, 1), end_date=date(2026, 12, 31)))
db.commit()
r = client.get("/planung").text
assert "<th>Start</th>" in r and "<th>Ende</th>" in r
assert "01.01.2026" in r and "31.12.2026" in r
assert 'name="start_date"' in r and 'name="end_date"' in r
def test_planning_page_shows_empty_suggestions_hint(client):
# UX-Regel: das "Vorschläge aus Buchungen"-Fieldset ist immer sichtbar,
# auch ohne Daten - statt komplett zu verschwinden zeigt es einen Hinweis.
@@ -234,7 +287,7 @@ def test_salden_page_stichtag_and_month_overview(client, db):
expected_stichtag = Decimal("1000.00") + Decimal("111.11") + Decimal("22.22")
r = client.get(f"/salden?stichtag={stichtag.isoformat()}")
assert r.status_code == 200
assert f"{expected_stichtag:.2f}" in r.text
assert eur(expected_stichtag) in r.text
# Monatsuebersicht: genau 3 Monatszeilen (m2, m1, m0).
assert r.text.count('data-month="') == 3
@@ -245,7 +298,7 @@ def test_salden_page_stichtag_and_month_overview(client, db):
# m2), d.h. NACH Buchung 1 (in m2), aber VOR Buchung 2 (die faellt erst
# in m1 selbst und darf den Monatsanfang nicht verfaelschen).
expected_m1_start = Decimal("1000.00") + Decimal("111.11")
assert f"{expected_m1_start:.2f}" in r.text
assert eur(expected_m1_start) in r.text
def test_salden_stichtag_garbage_falls_back_to_today(client):
@@ -257,6 +310,22 @@ def test_salden_stichtag_garbage_falls_back_to_today(client):
assert date.today().strftime('%d.%m.%Y') in r.text
def test_planung_hat_bearbeiten_formulare(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="EditR", amount=Decimal("-5.00"), rhythm="monthly", due_day=1))
db.add(PlannedItem(name="EditP", amount=Decimal("-7.00"), due=date(2026, 8, 1)))
db.add(Loan(name="EditL", principal=Decimal("1000.00"),
annual_rate_pct=Decimal("4.50"), term_months=12,
payout_date=date(2026, 8, 1), repayment_type="annuity"))
db.add(Scenario(name="EditS", description="d"))
db.commit()
r = client.get("/planung").text
assert r.count(">Bearbeiten</button>") >= 4
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
'hx-patch="/api/loans/', 'hx-patch="/api/scenarios/'):
assert fragment in r, fragment
def test_salden_shows_ohne_anker_footnote(client, db):
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
@@ -268,3 +337,23 @@ def test_salden_shows_ohne_anker_footnote(client, db):
r = client.get("/salden")
assert r.status_code == 200
assert "ohne Anker" in r.text and "relative Werte" in r.text
def test_szenario_gui_ende_und_einmalzahlungen(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="GUI-Ende", description="")
db.add(sc)
db.flush()
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
target_id=1, kind="ende", value=Decimal("0"),
end_date=date(2026, 8, 31)))
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-GUI",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
r = client.get("/planung").text
assert 'value="ende"' in r # neue Art im Dropdown
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
assert "31.08.2026" in r # ende-Modifikator zeigt Datum
assert "Einmalzahlungen in diesem Szenario" in r
assert "Zufluss-GUI" in r and "5.000,00" in r and "30.07.2026" in r
assert f'hx-post="/api/scenarios/{sc.id}/planned"' in r

View File

@@ -22,3 +22,15 @@ def test_account_transaction_roundtrip(db):
db.add(tx)
db.commit()
assert db.query(Transaction).one().amount == Decimal("-50.00")
def test_scenario_planned_item_roundtrip(db):
from app.models.tables import Scenario, ScenarioPlannedItem
sc = Scenario(name="SPI-Test", description="")
db.add(sc)
db.flush()
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Einmalzahlung",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
item = db.query(ScenarioPlannedItem).one()
assert item.amount == Decimal("5000.00") and item.scenario_id == sc.id

View File

@@ -34,6 +34,29 @@ def test_scenario_projection(client, db):
assert len(body["series"]) == 92
def test_projektion_mit_ende_modifier_und_szenario_einmalzahlung(client, db):
_seed_balance(db)
rec = client.post("/api/recurring", headers=H, json={
"name": "Miete-Proj", "amount": "-1000.00", "rhythm": "monthly",
"due_day": 1}).json()
sc = client.post("/api/scenarios", headers=H,
json={"name": "BestCase-Test", "include_planned": False}).json()
client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": rec["id"], "kind": "ende",
"end_date": "2026-08-31"})
client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"})
r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
params={"horizon_days": 150, "start_date": "2026-07-15"})
assert r.status_code == 200
series = {p["day"]: Decimal(p["balance"]) for p in r.json()["series"]}
# Start 1000; +5000 am 30.07.; Miete nur noch am 01.08. (ende 31.08.);
# include_planned=False, aber die SZENARIO-Zahlung zaehlt trotzdem.
assert series["2026-07-30"] == Decimal("6000.00")
assert series["2026-08-01"] == Decimal("5000.00")
assert series["2026-12-01"] == Decimal("5000.00") # keine Miete mehr ab Sept.
def test_loan_in_scenario_keeps_balance_positive(client, db):
_seed_balance(db)
client.post("/api/recurring", headers=H, json={
@@ -87,6 +110,30 @@ def test_suggest_recurring_two_months_no_suggestion(db):
assert suggest_recurring(db) == []
def test_recurring_ende_vor_start_wird_abgelehnt(client):
r = client.post("/api/recurring", headers=H, json={
"name": "Falschrum", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
"start_date": "2026-10-01", "end_date": "2026-09-01"})
assert r.status_code == 422
def test_recurring_start_ende_roundtrip_und_patch_validierung(client):
r = client.post("/api/recurring", headers=H, json={
"name": "Befristet", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
"start_date": "2026-01-01", "end_date": "2026-12-31"})
assert r.status_code == 201
item = r.json()
assert item["start_date"] == "2026-01-01" and item["end_date"] == "2026-12-31"
# PATCH, der Ende vor den (bestehenden) Start schieben will -> 422:
bad = client.patch(f"/api/recurring/{item['id']}", headers=H,
json={"end_date": "2025-06-30"})
assert bad.status_code == 422
# Datum explizit loeschen (null) ist erlaubt:
ok = client.patch(f"/api/recurring/{item['id']}", headers=H,
json={"end_date": None})
assert ok.status_code == 200 and ok.json()["end_date"] is None
def test_suggest_recurring_excludes_existing_recurring_item(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)

View File

@@ -57,3 +57,51 @@ def test_absolute_modifier_clamps_to_zero():
kind="absolute", value=Decimal("300"))]
flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31))
assert flows == [(date(2026, 8, 10), Decimal("0.00"))]
def test_ende_modifier_beendet_posten_im_szenario():
r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None)
m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31))
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31))
assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)]
assert all(a == Decimal("-2000.00") for _, a in flows)
def test_ende_modifier_minimum_mit_eigenem_ende():
# Posten endet selbst am 15.10.; Szenario-Ende 31.08. ist frueher -> gewinnt.
r = PlainRecurring(1, "Miete", Decimal("-100.00"), "monthly", 1,
None, date(2026, 10, 15), None)
m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31))
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31))
assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)]
# Umgekehrt: Szenario-Ende NACH eigenem Ende aendert nichts.
m_spaet = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 12, 31))
flows2 = build_cashflows([r], [], [], [], [m_spaet], date(2026, 7, 1), date(2026, 12, 31))
assert [d for d, _ in flows2][-1] == date(2026, 10, 1)
def test_ende_modifier_auf_kategorie_trifft_alle_posten_der_kategorie():
r1 = PlainRecurring(1, "Miete A", Decimal("-10.00"), "monthly", 1, None, None, 7)
r2 = PlainRecurring(2, "Miete B", Decimal("-20.00"), "monthly", 1, None, None, 7)
r3 = PlainRecurring(3, "Strom", Decimal("-5.00"), "monthly", 1, None, None, 9)
m = PlainModifier("category", 7, "ende", Decimal("0"), date(2026, 7, 31))
flows = build_cashflows([r1, r2, r3], [], [], [], [m],
date(2026, 7, 1), date(2026, 9, 30))
betraege = sorted(a for _, a in flows)
# Miete A/B nur je 1x (Juli), Strom 3x (Juli-Sept).
assert betraege == [Decimal("-20.00"), Decimal("-10.00"),
Decimal("-5.00"), Decimal("-5.00"), Decimal("-5.00")]
def test_ende_modifier_ohne_datum_wirkt_nicht():
r = PlainRecurring(1, "Miete", Decimal("-10.00"), "monthly", 1, None, None, None)
m = PlainModifier("recurring", 1, "ende", Decimal("0"), None)
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 9, 30))
assert len(flows) == 3
def test_ende_modifier_aendert_betraege_nicht():
r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None)
m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31))
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31))
assert all(a == Decimal("-2000.00") for _, a in flows)