From fe0e1b5e9376a7e5af6030c5b5d40243ec4a1015454e8be3b9bd1ac5323a7362 Mon Sep 17 00:00:00 2001
From: wlfb
Date: Mon, 20 Jul 2026 11:23:26 +0200
Subject: [PATCH] feat: deutsche Betragsanzeige (eur-Filter) und deutsche
GUI-Labels
Co-Authored-By: Claude Fable 5
---
finance/app/formats.py | 30 ++++++++++++++++
finance/app/routers/gui.py | 3 ++
finance/app/templates/_preview_table.html | 6 ++--
finance/app/templates/index.html | 10 +++---
finance/app/templates/planning.html | 39 ++++++++++----------
finance/app/templates/salden.html | 8 ++---
finance/app/templates/transactions.html | 2 +-
finance/tests/test_formats.py | 43 +++++++++++++++++++++++
finance/tests/test_gui.py | 32 ++++++++++++++---
9 files changed, 138 insertions(+), 35 deletions(-)
create mode 100644 finance/app/formats.py
create mode 100644 finance/tests/test_formats.py
diff --git a/finance/app/formats.py b/finance/app/formats.py
new file mode 100644
index 0000000..bf13b7d
--- /dev/null
+++ b/finance/app/formats.py
@@ -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)
diff --git a/finance/app/routers/gui.py b/finance/app/routers/gui.py
index d9ef6c6..da64f84 100644
--- a/finance/app/routers/gui.py
+++ b/finance/app/routers/gui.py
@@ -10,6 +10,7 @@ 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)
@@ -28,6 +29,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()
diff --git a/finance/app/templates/_preview_table.html b/finance/app/templates/_preview_table.html
index f0fb513..85bbf33 100644
--- a/finance/app/templates/_preview_table.html
+++ b/finance/app/templates/_preview_table.html
@@ -8,7 +8,7 @@
{{ tx.booking_date.strftime('%d.%m.%Y') }}
{{ tx.purpose }}
{{ tx.counterparty }}
- {{ '%.2f'|format(tx.amount) }} €
+ {{ tx.amount|eur }} €
{% if tx.is_duplicate %}Duplikat {% endif %}
{% else %}
@@ -25,7 +25,7 @@
{% else %}
Saldo-Differenz – bitte prüfen
{% 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 }}
diff --git a/finance/app/templates/index.html b/finance/app/templates/index.html
index bcd81c8..c47a71d 100644
--- a/finance/app/templates/index.html
+++ b/finance/app/templates/index.html
@@ -11,7 +11,7 @@
{% endif %}
- Gesamtsaldo: {{ '%.2f'|format(total) }} €
+ Gesamtsaldo: {{ total|eur }} €
@@ -26,10 +26,10 @@
{{ account.name }}
{{ account.bank }}
- {{ '%.2f'|format(balance) }} €
+ {{ balance|eur }} €
{% 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 %}
@@ -47,7 +47,7 @@
+ value="{{ account.anchor_balance|eur if account.anchor_balance is not none else '' }}" required>
Anker setzen
@@ -68,7 +68,7 @@
{{ item.date.strftime('%d.%m.%Y') }}
{{ item.name }}
- {{ '%.2f'|format(item.amount) }} €
+ {{ item.amount|eur }} €
{% else %}
Keine anstehenden Posten.
diff --git a/finance/app/templates/planning.html b/finance/app/templates/planning.html
index 2877281..e77d5e1 100644
--- a/finance/app/templates/planning.html
+++ b/finance/app/templates/planning.html
@@ -13,8 +13,8 @@
{% for r in recurring %}
{{ r.name }}
- {{ '%.2f'|format(r.amount) }} €
- {{ r.rhythm }}
+ {{ r.amount|eur }} €
+ {{ r.rhythm|de_label }}
{{ r.due_day }}
{{ category_names.get(r.category_id, '–') }}
@@ -39,7 +39,7 @@
Betrag
Rhythmus
- {% for rh in rhythms %}{{ rh }} {% endfor %}
+ {% for rh in rhythms %}{{ rh|de_label }} {% endfor %}
Fälligkeitstag
@@ -64,8 +64,8 @@
{% for s in suggestions %}
{{ s.name }}
- {{ '%.2f'|format(s.amount) }} €
- {{ s.rhythm }}
+ {{ s.amount|eur }} €
+ {{ s.rhythm|de_label }}
{{ s.due_day }}
';
document.getElementById('schedule-' + loanId).innerHTML = html;
diff --git a/finance/app/templates/salden.html b/finance/app/templates/salden.html
index c3b78d4..670f558 100644
--- a/finance/app/templates/salden.html
+++ b/finance/app/templates/salden.html
@@ -17,14 +17,14 @@
{% for account, balance in balances %}
{{ account.name }}
- {{ '%.2f'|format(balance) }} €
+ {{ balance|eur }} €
{% else %}
Keine Konten angelegt.
{% endfor %}
Gesamt
- {{ '%.2f'|format(total) }} €
+ {{ total|eur }} €
@@ -48,9 +48,9 @@
{{ row['month'].strftime('%m.%Y') }}
{% for account, value in row['values'] %}
- {{ '%.2f'|format(value) }} €
+ {{ value|eur }} €
{% endfor %}
- {{ '%.2f'|format(row['total']) }} €
+ {{ row['total']|eur }} €
{% else %}
Keine bestätigten Buchungen vorhanden.
diff --git a/finance/app/templates/transactions.html b/finance/app/templates/transactions.html
index 65d411c..d83a832 100644
--- a/finance/app/templates/transactions.html
+++ b/finance/app/templates/transactions.html
@@ -53,7 +53,7 @@
{{ account_names.get(tx.account_id, tx.account_id) }}
{{ tx.purpose }}
{{ tx.counterparty }}
- {{ '%.2f'|format(tx.amount) }}
+ {{ tx.amount|eur }}
diff --git a/finance/tests/test_formats.py b/finance/tests/test_formats.py
new file mode 100644
index 0000000..0e01e9a
--- /dev/null
+++ b/finance/tests/test_formats.py
@@ -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"}
diff --git a/finance/tests/test_gui.py b/finance/tests/test_gui.py
index b01869d..095643f 100644
--- a/finance/tests/test_gui.py
+++ b/finance/tests/test_gui.py
@@ -1,7 +1,8 @@
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, RecurringItem
H = {"Authorization": "Bearer test-key"}
@@ -34,7 +35,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 +43,29 @@ 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_import_page_has_dropzone_and_list(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import")
@@ -234,7 +258,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 +269,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):