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 target_id: int
kind: str kind: str
value: Decimal value: Decimal
end_date: date | None = None
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | 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 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], def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned],
loan_schedules: list[list[Installment]], loan_schedules: list[list[Installment]],
loan_payouts: list[tuple[date, Decimal]], loan_payouts: list[tuple[date, Decimal]],
@@ -66,7 +80,7 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
if amount is None: if amount is None:
continue 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, r.end_date): r.start_date, _effective_end(r, modifiers)):
flows.append((d, amount)) flows.append((d, amount))
for p in planned: for p in planned:
amount = _modified(p.amount, p.category_id, None, modifiers) 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) 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
# 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): class ProjectionPoint(Base):

View File

@@ -10,9 +10,11 @@ from app.auth import COOKIE, session_valid
from app.db import get_session from app.db import get_session
from app.engine.loans import add_months from app.engine.loans import add_months
from app.engine.recurrence import occurrences from app.engine.recurrence import occurrences
from app.formats import de_label, eur
from app.models.tables import (Account, Category, PlannedItem, from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem, ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier, Transaction) ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, 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
@@ -28,6 +30,8 @@ PAGE_SIZE = 50
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
templates.env.globals["app_version"] = get_version() templates.env.globals["app_version"] = get_version()
templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label
router = APIRouter() router = APIRouter()
@@ -261,10 +265,15 @@ def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]
modifiers = session.execute( modifiers = session.execute(
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id) select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
).scalars().all() ).scalars().all()
planned_items = session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == sc.id)
.order_by(ScenarioPlannedItem.due)).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,
"result": session.get(ProjectionResult, sc.id), "result": session.get(ProjectionResult, sc.id),
}) })
return rows return rows
@@ -293,5 +302,5 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
"scenario_rows": _scenario_rows(session, scenarios, loans), "scenario_rows": _scenario_rows(session, scenarios, loans),
"rhythms": ["monthly", "quarterly", "yearly"], "rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"], "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 typing import Literal
from fastapi import APIRouter, Depends, HTTPException 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 import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -28,6 +28,13 @@ class RecurringIn(BaseModel):
end_date: date | None = None end_date: date | None = None
category_id: int | 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): class RecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -90,6 +97,10 @@ def patch_recurring(item_id: int, data: RecurringPatch,
_check_category(session, fields["category_id"]) _check_category(session, fields["category_id"])
for key, value in fields.items(): for key, value in fields.items():
setattr(item, key, value) 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.commit()
session.refresh(item) session.refresh(item)
return RecurringOut.model_validate(item) return RecurringOut.model_validate(item)

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException 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 import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -11,7 +11,8 @@ from app.auth import require_auth
from app.config import get_settings 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)
from app.services.projection_service import run_projection from app.services.projection_service import run_projection
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"], router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
@@ -36,12 +37,26 @@ class ScenarioPatch(BaseModel):
include_recurring: bool | None = None include_recurring: bool | None = None
include_planned: 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): class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"] target_type: Literal["category", "recurring"]
target_id: int target_id: int
kind: Literal["percent", "absolute", "remove"] kind: Literal["percent", "absolute", "remove", "ende"]
value: Decimal = Decimal("0") 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): class ModifierOut(ModifierIn):
@@ -117,6 +132,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
ScenarioLoan.scenario_id == scenario_id)) ScenarioLoan.scenario_id == scenario_id))
session.execute(delete(ScenarioModifier).where( session.execute(delete(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario_id)) ScenarioModifier.scenario_id == scenario_id))
session.execute(delete(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario_id))
session.delete(scenario) session.delete(scenario)
session.commit() session.commit()
@@ -164,6 +181,50 @@ def delete_modifier(scenario_id: int, mod_id: int,
session.commit() 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) @router.post("/{scenario_id}/project", response_model=ProjectionOut)
def project_scenario(scenario_id: int, horizon_days: int | None = None, def project_scenario(scenario_id: int, horizon_days: int | None = None,
start_date: date | None = None, start_date: date | None = None,

View File

@@ -12,7 +12,8 @@ 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)
from app.services.balances import total_balance 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: if scenario.include_planned:
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id) planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
for p in session.execute(select(PlannedItem)).scalars()] 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( loans = session.execute(
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id) select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all() .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) l.term_months, l.payout_date, l.repayment_type)
for l in loans] for l in loans]
payouts = [(l.payout_date, Decimal(l.principal)) 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( for m in session.execute(select(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario.id)).scalars()] ScenarioModifier.scenario_id == scenario.id)).scalars()]
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers, 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.booking_date.strftime('%d.%m.%Y') }}</td>
<td>{{ tx.purpose }}</td> <td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</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> <td>{% if tx.is_duplicate %}<span class="badge badge-warning">Duplikat</span>{% endif %}</td>
</tr> </tr>
{% else %} {% else %}
@@ -25,7 +25,7 @@
{% else %} {% else %}
<span class="badge badge-error">Saldo-Differenz bitte prüfen</span> <span class="badge badge-error">Saldo-Differenz bitte prüfen</span>
{% endif %} {% endif %}
· Eröffnungssaldo: {{ '%.2f'|format(statement.opening_balance) if statement.opening_balance is not none else '' }} € · Eröffnungssaldo: {{ statement.opening_balance|eur if statement.opening_balance is not none else '' }} €
· Endsaldo: {{ '%.2f'|format(statement.closing_balance) if statement.closing_balance is not none else '' }} € · Endsaldo: {{ statement.closing_balance|eur if statement.closing_balance is not none else '' }} €
· Duplikate: {{ duplicates }} · Duplikate: {{ duplicates }}
</p> </p>

View File

@@ -50,6 +50,14 @@
out[k] = null; out[k] = null;
} else if (kind === 'int') { } else if (kind === 'int') {
out[k] = parseInt(v, 10); 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 { } else {
out[k] = v; out[k] = v;
} }

View File

@@ -70,6 +70,11 @@
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand kürzen oder ganz streichen). "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“ 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>
<li> <li>
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach <strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach

View File

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

View File

@@ -7,17 +7,20 @@
<h2>Wiederkehrende Posten</h2> <h2>Wiederkehrende Posten</h2>
<table> <table>
<thead> <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> </thead>
<tbody> <tbody>
{% for r in recurring %} {% for r in recurring %}
<tr> <tr id="rec-row-{{ r.id }}">
<td>{{ r.name }}</td> <td>{{ r.name }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ '%.2f'|format(r.amount) }} €</td> <td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td>
<td>{{ r.rhythm }}</td> <td>{{ r.rhythm|de_label }}</td>
<td>{{ r.due_day }}</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>{{ category_names.get(r.category_id, '') }}</td>
<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" <form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none"
hx-confirm="„{{ r.name }}“ wirklich löschen?" hx-confirm="„{{ r.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -25,8 +28,33 @@
</form> </form>
</td> </td>
</tr> </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 %} {% else %}
<tr><td colspan="6">Noch keine wiederkehrenden Posten.</td></tr> <tr><td colspan="8">Noch keine wiederkehrenden Posten.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -36,13 +64,15 @@
<form hx-ext="json-form" hx-post="/api/recurring" hx-swap="none" <form hx-ext="json-form" hx-post="/api/recurring" 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>Name <input type="text" name="name" required></label> <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 <label>Rhythmus
<select name="rhythm" data-type="str"> <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> </select>
</label> </label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required></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 <label>Kategorie
<select name="category_id" data-type="int"> <select name="category_id" data-type="int">
<option value=""></option> <option value=""></option>
@@ -64,8 +94,8 @@
{% for s in suggestions %} {% for s in suggestions %}
<tr> <tr>
<td>{{ s.name }}</td> <td>{{ s.name }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ '%.2f'|format(s.amount) }} €</td> <td class="{{ 'neg' if s.amount < 0 else '' }}">{{ s.amount|eur }} €</td>
<td>{{ s.rhythm }}</td> <td>{{ s.rhythm|de_label }}</td>
<td>{{ s.due_day }}</td> <td>{{ s.due_day }}</td>
<td> <td>
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none" <form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
@@ -96,12 +126,13 @@
</thead> </thead>
<tbody> <tbody>
{% for p in planned %} {% for p in planned %}
<tr> <tr id="pln-row-{{ p.id }}">
<td>{{ p.name }}</td> <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>{{ p.due.strftime('%d.%m.%Y') }}</td>
<td>{{ category_names.get(p.category_id, '') }}</td> <td>{{ category_names.get(p.category_id, '') }}</td>
<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" <form class="inline-form" hx-delete="/api/planned/{{ p.id }}" hx-swap="none"
hx-confirm="„{{ p.name }}“ wirklich löschen?" hx-confirm="„{{ p.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -109,6 +140,24 @@
</form> </form>
</td> </td>
</tr> </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 %} {% else %}
<tr><td colspan="5">Noch keine Einmalposten.</td></tr> <tr><td colspan="5">Noch keine Einmalposten.</td></tr>
{% endfor %} {% endfor %}
@@ -120,7 +169,7 @@
<form hx-ext="json-form" hx-post="/api/planned" hx-swap="none" <form hx-ext="json-form" hx-post="/api/planned" 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>Name <input type="text" name="name" required></label> <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>Fällig am <input type="date" name="due" required></label>
<label>Kategorie <label>Kategorie
<select name="category_id" data-type="int"> <select name="category_id" data-type="int">
@@ -141,14 +190,15 @@
</thead> </thead>
<tbody> <tbody>
{% for l in loans %} {% for l in loans %}
<tr> <tr id="loan-row-{{ l.id }}">
<td>{{ l.name }}</td> <td>{{ l.name }}</td>
<td>{{ '%.2f'|format(l.principal) }} €</td> <td>{{ l.principal|eur }} €</td>
<td>{{ '%.2f'|format(l.annual_rate_pct) }} %</td> <td>{{ l.annual_rate_pct|eur }} %</td>
<td>{{ l.term_months }} Monate</td> <td>{{ l.term_months }} Monate</td>
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td> <td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
<td>{{ l.repayment_type }}</td> <td>{{ l.repayment_type|de_label }}</td>
<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" <form class="inline-form" hx-delete="/api/loans/{{ l.id }}" hx-swap="none"
hx-confirm="„{{ l.name }}“ wirklich löschen?" hx-confirm="„{{ l.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -156,6 +206,27 @@
</form> </form>
</td> </td>
</tr> </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> <tr>
<td colspan="7"> <td colspan="7">
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}"> <details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
@@ -175,13 +246,13 @@
<form hx-ext="json-form" hx-post="/api/loans" hx-swap="none" <form hx-ext="json-form" hx-post="/api/loans" 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>Name <input type="text" name="name" required></label> <label>Name <input type="text" name="name" required></label>
<label>Darlehenssumme <input type="text" name="principal" 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" 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" 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">
{% 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> </select>
</label> </label>
<button type="submit">Anlegen</button> <button type="submit">Anlegen</button>
@@ -195,11 +266,23 @@
{% set sc = row.scenario %} {% set sc = row.scenario %}
<fieldset> <fieldset>
<legend>{{ sc.name }}</legend> <legend>{{ sc.name }}</legend>
<p> <p id="sc-row-{{ sc.id }}">
{{ sc.description }} {{ sc.description }}
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }} · Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }} · Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
</p> </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> <details>
<summary>Kredite zuordnen</summary> <summary>Kredite zuordnen</summary>
@@ -227,8 +310,11 @@
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }} {% 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 %} {% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
</td> </td>
<td>{{ m.kind }}</td> <td>{{ m.kind|de_label }}</td>
<td>{{ '%.2f'|format(m.value) }}</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> <td>
<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"
hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -265,15 +351,49 @@
</label> </label>
</span> </span>
<label>Art <label>Art
<select name="kind" data-type="str"> <select name="kind" data-type="str" onchange="onModKindChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k }}</option>{% endfor %} {% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
</select> </select>
</label> </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> <button type="submit">Modifikator hinzufügen</button>
</form> </form>
</details> </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" <form hx-post="/api/scenarios/{{ sc.id }}/project" 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()}">
<button type="submit">Durchrechnen</button> <button type="submit">Durchrechnen</button>
@@ -281,7 +401,7 @@
{% if row.result %} {% if row.result %}
<p> <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_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 %} {% 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. 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 // json-form-Extension ist zentral in base.html registriert (eine
// Definition fuer alle Templates, inkl. data-type="bool"-Handling). // 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) { function onModTargetTypeChange(select) {
var form = select.closest('form'); var form = select.closest('form');
var catSpan = form.querySelector('.mod-target-category'); var catSpan = form.querySelector('.mod-target-category');
@@ -324,6 +450,13 @@
recSelect.disabled = isCategory; 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) { 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) {
@@ -343,6 +476,9 @@
var parts = iso.split('-'); var parts = iso.split('-');
return parts[2] + '.' + parts[1] + '.' + parts[0]; return parts[2] + '.' + parts[1] + '.' + parts[0];
} }
function fmtEur(n) {
return Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function loadLoanSchedule(loanId) { function loadLoanSchedule(loanId) {
if (loadedSchedules[loanId]) return; if (loadedSchedules[loanId]) return;
loadedSchedules[loanId] = true; loadedSchedules[loanId] = true;
@@ -353,10 +489,10 @@
+ '<th>Tilgung</th><th>Restschuld</th></tr></thead><tbody>'; + '<th>Tilgung</th><th>Restschuld</th></tr></thead><tbody>';
rows.forEach(function (r) { rows.forEach(function (r) {
html += '<tr><td>' + formatIsoDate(r.due) + '</td>' html += '<tr><td>' + formatIsoDate(r.due) + '</td>'
+ '<td>' + Number(r.payment).toFixed(2) + ' €</td>' + '<td>' + fmtEur(r.payment) + ' €</td>'
+ '<td>' + Number(r.interest).toFixed(2) + ' €</td>' + '<td>' + fmtEur(r.interest) + ' €</td>'
+ '<td>' + Number(r.principal).toFixed(2) + ' €</td>' + '<td>' + fmtEur(r.principal) + ' €</td>'
+ '<td>' + Number(r.remaining).toFixed(2) + ' €</td></tr>'; + '<td>' + fmtEur(r.remaining) + ' €</td></tr>';
}); });
html += '</tbody></table>'; html += '</tbody></table>';
document.getElementById('schedule-' + loanId).innerHTML = html; document.getElementById('schedule-' + loanId).innerHTML = html;

View File

@@ -17,14 +17,14 @@
{% for account, balance in balances %} {% for account, balance in balances %}
<tr> <tr>
<td class="account">{{ account.name }}</td> <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> </tr>
{% else %} {% else %}
<tr><td colspan="2">Keine Konten angelegt.</td></tr> <tr><td colspan="2">Keine Konten angelegt.</td></tr>
{% endfor %} {% endfor %}
<tr class="total-row"> <tr class="total-row">
<td><strong>Gesamt</strong></td> <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> </tr>
</tbody> </tbody>
</table> </table>
@@ -48,9 +48,9 @@
<tr data-month="{{ row['month'].isoformat() }}"> <tr data-month="{{ row['month'].isoformat() }}">
<td>{{ row['month'].strftime('%m.%Y') }}</td> <td>{{ row['month'].strftime('%m.%Y') }}</td>
{% for account, value in row['values'] %} {% 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 %} {% 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> </tr>
{% else %} {% else %}
<tr><td colspan="{{ accounts|length + 2 }}">Keine bestätigten Buchungen vorhanden.</td></tr> <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 class="account">{{ account_names.get(tx.account_id, tx.account_id) }}</td>
<td>{{ tx.purpose }}</td> <td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</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> <td>
<select name="category_id" data-type="int" hx-ext="json-form" <select name="category_id" data-type="int" hx-ext="json-form"
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none"> 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, r = client.patch(f"/api/transactions/{tx_id}", headers=H,
json={"category_id": 9999}) json={"category_id": 9999})
assert r.status_code == 404 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 datetime import date, timedelta
from decimal import Decimal 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"} H = {"Authorization": "Bearer test-key"}
@@ -34,7 +36,7 @@ def test_index_shows_account_anchor(client, db):
r = client.get("/") r = client.get("/")
assert r.status_code == 200 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 "30.06.2026" in r.text
assert "kein Anker" in r.text assert "kein Anker" in r.text
# Inline-Pflegeformular fuer den Anker muss je Konto vorhanden sein. # 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 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): def test_import_page_has_dropzone_and_list(client):
client.post("/login", data={"username": "admin", "password": "geheim"}) client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import") r = client.get("/import")
@@ -185,6 +226,18 @@ def test_planning_page_has_all_sections(client):
assert heading in r.text 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): def test_planning_page_shows_empty_suggestions_hint(client):
# UX-Regel: das "Vorschläge aus Buchungen"-Fieldset ist immer sichtbar, # UX-Regel: das "Vorschläge aus Buchungen"-Fieldset ist immer sichtbar,
# auch ohne Daten - statt komplett zu verschwinden zeigt es einen Hinweis. # 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") expected_stichtag = Decimal("1000.00") + Decimal("111.11") + Decimal("22.22")
r = client.get(f"/salden?stichtag={stichtag.isoformat()}") r = client.get(f"/salden?stichtag={stichtag.isoformat()}")
assert r.status_code == 200 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). # Monatsuebersicht: genau 3 Monatszeilen (m2, m1, m0).
assert r.text.count('data-month="') == 3 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 # m2), d.h. NACH Buchung 1 (in m2), aber VOR Buchung 2 (die faellt erst
# in m1 selbst und darf den Monatsanfang nicht verfaelschen). # in m1 selbst und darf den Monatsanfang nicht verfaelschen).
expected_m1_start = Decimal("1000.00") + Decimal("111.11") 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): 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 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): def test_salden_shows_ohne_anker_footnote(client, db):
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte # UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt. # gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
@@ -268,3 +337,23 @@ def test_salden_shows_ohne_anker_footnote(client, db):
r = client.get("/salden") r = client.get("/salden")
assert r.status_code == 200 assert r.status_code == 200
assert "ohne Anker" in r.text and "relative Werte" in r.text 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.add(tx)
db.commit() db.commit()
assert db.query(Transaction).one().amount == Decimal("-50.00") 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 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): def test_loan_in_scenario_keeps_balance_positive(client, db):
_seed_balance(db) _seed_balance(db)
client.post("/api/recurring", headers=H, json={ client.post("/api/recurring", headers=H, json={
@@ -87,6 +110,30 @@ def test_suggest_recurring_two_months_no_suggestion(db):
assert suggest_recurring(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): def test_suggest_recurring_excludes_existing_recurring_item(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro") acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc) db.add(acc)

View File

@@ -57,3 +57,51 @@ def test_absolute_modifier_clamps_to_zero():
kind="absolute", value=Decimal("300"))] kind="absolute", value=Decimal("300"))]
flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31)) flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31))
assert flows == [(date(2026, 8, 10), Decimal("0.00"))] 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)