From 07db68aea2cda463a5cb2c39d2ebb75380b599f7 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 14 May 2026 22:00:52 +0200 Subject: [PATCH 001/113] Fix: Geburtstag aller Hunde + Kotbeutel-Stationen in Stats (SW by-v962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - worlds.js: bdayDog = _dogs.find(...) — Geburtstag gilt für alle Hunde, nicht nur den aktiven - Banner, KI-Call, "Was hat sich X gewünscht?" nutzen bdayDog.name - stats.py: kotbeutel-Count aus user_map_pois WHERE type='kotbeutel' - landing: Stats-Band 5. Kachel "Kotbeutel-Stationen" --- backend/main.py | 2 +- backend/routes/stats.py | 14 +++++++++----- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 19 ++++++++++--------- backend/static/landing.html | 7 ++++++- backend/static/sw.js | 2 +- 6 files changed, 28 insertions(+), 18 deletions(-) diff --git a/backend/main.py b/backend/main.py index f1025de..533923e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "961" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "962" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/stats.py b/backend/routes/stats.py index 49fe778..cfaecca 100644 --- a/backend/routes/stats.py +++ b/backend/routes/stats.py @@ -30,19 +30,23 @@ async def public_stats(): if _pub_cache["data"] and now - _pub_cache["ts"] < _PUB_TTL: return _pub_cache["data"] with db() as conn: - users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] - dogs = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] - km = conn.execute( + users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + dogs = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] + km = conn.execute( "SELECT ROUND(COALESCE(SUM(distanz_km),0),0) FROM routes" ).fetchone()[0] - posts = conn.execute("SELECT COUNT(*) FROM forum_posts").fetchone()[0] - diary = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0] + posts = conn.execute("SELECT COUNT(*) FROM forum_posts").fetchone()[0] + diary = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0] + kotbeutel = conn.execute( + "SELECT COUNT(*) FROM user_map_pois WHERE type='kotbeutel'" + ).fetchone()[0] data = { "users": users, "dogs": dogs, "km": int(km or 0), "forum_posts": posts, "diary_entries": diary, + "kotbeutel": kotbeutel, } _pub_cache["data"] = data _pub_cache["ts"] = now diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 6594b45..f630663 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '961'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '962'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index b250a29..ca5fdce 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1342,8 +1342,9 @@ window.Worlds = (() => { if (mmdd === `${mt}-${dt}`) return 'tomorrow'; return null; } - const bday = _birthdayState(dog.geburtstag); - const bdayYear = dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null; + const bdayDog = _dogs.find(d => _birthdayState(d.geburtstag)) || null; + const bday = bdayDog ? _birthdayState(bdayDog.geburtstag) : null; + const bdayYear = bdayDog?.geburtstag ? new Date().getFullYear() - parseInt(bdayDog.geburtstag.slice(0, 4)) : null; const [streakRes, diaryRes] = await Promise.allSettled([ _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`), @@ -1428,8 +1429,8 @@ window.Worlds = (() => {
${bday === 'today' - ? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(dog.name)}!` - : `Morgen hat ${_esc(dog.name)} Geburtstag!`} + ? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(bdayDog.name)}!` + : `Morgen hat ${_esc(bdayDog.name)} Geburtstag!`}
@@ -1443,7 +1444,7 @@ window.Worlds = (() => {
` : ''}
- ${bday === 'today' ? 'Was hat sich Ban Yaro gewünscht?' : 'KI-Überraschungsideen'} + ${bday === 'today' ? `Was hat sich ${_esc(bdayDog.name)} gewünscht?` : 'KI-Überraschungsideen'}
${bday === 'today' && new Date().getHours() >= 18 ? ` @@ -1563,10 +1564,10 @@ window.Worlds = (() => { try { const res = await API.post('/ki/geburtstag', { - dog_id: dog.id, - name: dog.name, - rasse: dog.rasse || null, - alter: dog.alter_jahre ? Math.round(dog.alter_jahre) : null, + dog_id: bdayDog.id, + name: bdayDog.name, + rasse: bdayDog.rasse || null, + alter: bdayDog.alter_jahre ? Math.round(bdayDog.alter_jahre) : null, mode: bdayMode, }); const body = ov.querySelector('#bday-ki-body'); diff --git a/backend/static/landing.html b/backend/static/landing.html index 49a2a5d..9e68f67 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -824,6 +824,10 @@
Forum-Beiträge
+
+
+
Kotbeutel-Stationen
+
@@ -1579,7 +1583,8 @@ set('big-users', d.users); set('big-dogs', d.dogs); set('big-km', d.km); - set('big-posts', d.forum_posts); + set('big-posts', d.forum_posts); + set('big-kotbeutel', d.kotbeutel); var heroStats = document.getElementById('hero-stats'); if (heroStats && d.users > 0) heroStats.style.display = 'flex'; diff --git a/backend/static/sw.js b/backend/static/sw.js index ddcd02a..16dc02a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v961'; +const CACHE_VERSION = 'by-v962'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 58046ce0c7f6f4d11573f2088cd32af28b32f387 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 14 May 2026 22:07:17 +0200 Subject: [PATCH 002/113] =?UTF-8?q?Fix:=20Kotbeutel-Stationen=20=E2=86=92?= =?UTF-8?q?=20M=C3=BClleimer=20f=C3=BCr=20Kotbeutel=20in=20Stats-Band=20(S?= =?UTF-8?q?W=20by-v963)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/js/app.js | 2 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f630663..ce8e122 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '962'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '963'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/landing.html b/backend/static/landing.html index 9e68f67..bd01aa5 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -826,7 +826,7 @@
-
Kotbeutel-Stationen
+
Mülleimer für Kotbeutel
diff --git a/backend/static/sw.js b/backend/static/sw.js index 16dc02a..4a7131e 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v962'; +const CACHE_VERSION = 'by-v963'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From cf6e5920ae17a2e55ab65914e15c9150fe8bef5c Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 14 May 2026 22:15:17 +0200 Subject: [PATCH 003/113] =?UTF-8?q?Fix:=20APP=5FVER-Mismatch=20(Dauer-Aktu?= =?UTF-8?q?alisieren-Bug),=20M=C3=BClleimer-Zahl=20im=20Hero=20(SW=20by-v9?= =?UTF-8?q?64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/landing.html | 3 +++ backend/static/sw.js | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/main.py b/backend/main.py index 533923e..9ad6007 100644 --- a/backend/main.py +++ b/backend/main.py @@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "962" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "964" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index ce8e122..91da90f 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '963'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '964'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/landing.html b/backend/static/landing.html index bd01aa5..2b30132 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -747,6 +747,8 @@ Hunde · km Gassi-Wege + · + Mülleimer @@ -1584,6 +1586,7 @@ set('big-dogs', d.dogs); set('big-km', d.km); set('big-posts', d.forum_posts); + set('stat-kotbeutel', d.kotbeutel); set('big-kotbeutel', d.kotbeutel); var heroStats = document.getElementById('hero-stats'); diff --git a/backend/static/sw.js b/backend/static/sw.js index 4a7131e..c2933b0 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v963'; +const CACHE_VERSION = 'by-v964'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 129badf010ae3402865f865f88e8ebef651e7865 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 14 May 2026 22:37:29 +0200 Subject: [PATCH 004/113] =?UTF-8?q?Fix:=20Hero-Stats=20zeigt=20vollst?= =?UTF-8?q?=C3=A4ndigen=20Text=20'M=C3=BClleimer=20f=C3=BCr=20Kotbeutel'?= =?UTF-8?q?=20(SW=20by-v965)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index 9ad6007..8f75144 100644 --- a/backend/main.py +++ b/backend/main.py @@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "964" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "965" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 91da90f..3555d46 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '964'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '965'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/landing.html b/backend/static/landing.html index 2b30132..39d1399 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -748,7 +748,7 @@ · km Gassi-Wege · - Mülleimer + Mülleimer für Kotbeutel diff --git a/backend/static/sw.js b/backend/static/sw.js index c2933b0..97540a7 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v964'; +const CACHE_VERSION = 'by-v965'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 6495a5ff6bdf31d14d01c82c9e81fb704e9af737 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 14 May 2026 22:45:50 +0200 Subject: [PATCH 005/113] Fix: Stats-Kotbeutel aus osm_pois statt user_map_pois (zeigt 125.750 statt 2) --- backend/routes/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/stats.py b/backend/routes/stats.py index cfaecca..e276ca6 100644 --- a/backend/routes/stats.py +++ b/backend/routes/stats.py @@ -38,7 +38,7 @@ async def public_stats(): posts = conn.execute("SELECT COUNT(*) FROM forum_posts").fetchone()[0] diary = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0] kotbeutel = conn.execute( - "SELECT COUNT(*) FROM user_map_pois WHERE type='kotbeutel'" + "SELECT COUNT(*) FROM osm_pois WHERE type='kotbeutel'" ).fetchone()[0] data = { "users": users, From c032b9a3fbb25a4c81e0578688d5fabf735e89bd Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 14 May 2026 22:47:14 +0200 Subject: [PATCH 006/113] =?UTF-8?q?Fix:=20Stats-Kotbeutel=20=E2=86=92=20wa?= =?UTF-8?q?ste=5Fbasket=20Typ=20in=20osm=5Fpois=20(zeigt=20echte=20M=C3=BC?= =?UTF-8?q?lleimer-Zahl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/stats.py b/backend/routes/stats.py index e276ca6..985a883 100644 --- a/backend/routes/stats.py +++ b/backend/routes/stats.py @@ -38,7 +38,7 @@ async def public_stats(): posts = conn.execute("SELECT COUNT(*) FROM forum_posts").fetchone()[0] diary = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0] kotbeutel = conn.execute( - "SELECT COUNT(*) FROM osm_pois WHERE type='kotbeutel'" + "SELECT COUNT(*) FROM osm_pois WHERE type='waste_basket'" ).fetchone()[0] data = { "users": users, From 9c359bb07e0b767e96cb4d540e8e1756b4c20204 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 09:56:42 +0200 Subject: [PATCH 007/113] =?UTF-8?q?Feat:=20Rechnungs-Management=20Tab=20in?= =?UTF-8?q?=20Admin-Oberfl=C3=A4che?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer 'Rechnungen'-Tab mit vollständiger Invoice-Verwaltung: - Invoice-Liste mit Status-Badges (draft/sent/paid/cancelled) und kontextuellen Aktionen - Modal: Neue Rechnung erstellen (dynamische Positionen, Live-Vorschau Netto/Brutto) - Modal: Als bezahlt markieren (Datum + Betrag) - Modal: Stornieren mit Pflichtgrund - Modal: Detail-Ansicht mit Positionen-Tabelle - Cashflow-View: Übersichtskacheln + Monatstabelle + Quartalsbericht-CSV-Download - Action-Items Badge für offene Rechnungen (invoices_unpaid aus action-items API) --- backend/static/js/pages/admin.js | 714 +++++++++++++++++++++++++++++++ 1 file changed, 714 insertions(+) diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 8d8e780..c519e48 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -27,6 +27,7 @@ window.Page_admin = (() => { { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, { id: 'referrals', label: 'Referrals', icon: 'share-network' }, { id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' }, + { id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' }, ]; // ------------------------------------------------------------------ @@ -97,6 +98,7 @@ window.Page_admin = (() => { { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, { key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' }, { key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' }, + { key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' }, ]; const open = items.filter(i => d[i.key] > 0); @@ -166,6 +168,7 @@ window.Page_admin = (() => { case 'uebungen_admin': await _renderUebungenAdmin(el); break; case 'referrals': await _renderReferrals(el); break; case 'upgrades': await _renderUpgrades(el); break; + case 'rechnungen': await _renderRechnungen(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); @@ -3609,6 +3612,717 @@ window.Page_admin = (() => { }); } + // ------------------------------------------------------------------ + // TAB: RECHNUNGEN + // ------------------------------------------------------------------ + async function _renderRechnungen(el) { + let _subView = 'liste'; // 'liste' | 'cashflow' + + async function _load() { + el.innerHTML = ` +
+
+ + +
+ ${_subView === 'liste' ? ` + ` : ''} +
+
+
Lade…
+
+ `; + + el.querySelectorAll('.adm-inv-nav').forEach(btn => { + btn.addEventListener('click', () => { + _subView = btn.dataset.v; + _load(); + }); + }); + + el.querySelector('#adm-inv-new')?.addEventListener('click', () => _openNeueRechnungModal(_load)); + + const content = el.querySelector('#adm-inv-content'); + if (_subView === 'liste') { + await _loadInvoiceList(content, _load); + } else { + await _loadCashflow(content); + } + } + + await _load(); + } + + async function _loadInvoiceList(el, reload) { + let invoices; + try { + invoices = await API.get('/admin/invoices'); + } catch (e) { + el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Rechnungen konnten nicht geladen werden.'); + return; + } + + if (!invoices.length) { + el.innerHTML = _emptyState('receipt', 'Keine Rechnungen', 'Noch keine Rechnungen erstellt.'); + return; + } + + const _statusBadge = status => { + const cfg = { + draft: ['Entwurf', 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'], + sent: ['Versendet', 'var(--c-primary)', 'var(--c-primary-subtle,#eff6ff)','var(--c-primary)'], + paid: ['Bezahlt', 'var(--c-success,#16a34a)','#d1fae5', 'var(--c-success,#16a34a)'], + cancelled: ['Storniert', 'var(--c-danger,#dc2626)', '#fee2e2', 'var(--c-danger,#dc2626)'], + }; + const [label, color, bg, border] = cfg[status] || [status, 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)']; + return `${label}`; + }; + + const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—'; + const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; + + const rows = invoices.map((inv, i) => { + const actions = []; + if (inv.status === 'draft') { + actions.push(``); + } + if (inv.status === 'sent') { + actions.push(``); + actions.push(``); + } + if (inv.status === 'paid' || inv.status === 'cancelled') { + actions.push(``); + } + + return ` + + + ${_esc(inv.invoice_number)} + + +
${_esc(inv.recipient_name)}
+
${_esc(inv.recipient_email || '')}
+ + + ${_fmtEur(inv.amount_gross)} + + ${_statusBadge(inv.status)} + + ${_fmtDate(inv.created_at)} + + +
${actions.join('')}
+ + `; + }).join(''); + + el.innerHTML = ` +
+
+ + + + + + + + + + + + ${rows} +
NummerEmpfängerBetragStatusErstellt
+
+
+ `; + + // Senden + el.querySelectorAll('.adm-inv-send').forEach(btn => { + btn.addEventListener('click', async () => { + const ok = await UI.modal.confirm({ + title: `Rechnung ${btn.dataset.num} versenden?`, + message: 'Die Rechnung wird als PDF erzeugt und per E-Mail an den Empfänger versendet.', + confirmText: 'Jetzt versenden', + }); + if (!ok) return; + btn.disabled = true; + try { + await API.post(`/admin/invoices/${btn.dataset.id}/send`, {}); + UI.toast.success('Rechnung versendet.'); + reload(); + } catch (e) { + UI.toast.error(e.message || 'Fehler beim Versenden.'); + btn.disabled = false; + } + }); + }); + + // Als bezahlt markieren + el.querySelectorAll('.adm-inv-pay').forEach(btn => { + btn.addEventListener('click', () => _openBezahltModal(btn.dataset.id, Number(btn.dataset.amount), reload)); + }); + + // Stornieren + el.querySelectorAll('.adm-inv-cancel').forEach(btn => { + btn.addEventListener('click', () => _openStornoModal(btn.dataset.id, btn.dataset.num, reload)); + }); + + // Details + el.querySelectorAll('.adm-inv-detail').forEach(btn => { + btn.addEventListener('click', () => _openDetailModal(btn.dataset.id)); + }); + } + + function _openNeueRechnungModal(reload) { + const id = `inv-new-${Date.now()}`; + + UI.modal.open({ + title: `${UI.icon('receipt')} Neue Rechnung erstellen`, + body: ` +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+ +
+ Netto: — +
+
+ +
+ + +
+ +
+ `, + footer: ` + + + `, + }); + + // Items-Container und Hilfsfunktionen + const itemsContainer = document.getElementById(`${id}-items`); + const previewEl = document.getElementById(`${id}-preview`); + const discountEl = document.getElementById(`${id}-discount`); + + function _addItem(desc = '', qty = 1, price = 0) { + const itemEl = document.createElement('div'); + itemEl.className = 'adm-inv-item-row'; + itemEl.style.cssText = 'display:grid;grid-template-columns:1fr 60px 100px auto;gap:var(--space-2);align-items:center'; + itemEl.innerHTML = ` + + + + + `; + itemEl.querySelector('.inv-item-remove').addEventListener('click', () => { + if (itemsContainer.querySelectorAll('.adm-inv-item-row').length > 1) { + itemEl.remove(); + _updatePreview(); + } + }); + itemEl.querySelectorAll('input').forEach(inp => inp.addEventListener('input', _updatePreview)); + itemsContainer.appendChild(itemEl); + _updatePreview(); + } + + function _updatePreview() { + let netto = 0; + itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { + const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 0; + const price = parseFloat(row.querySelector('.inv-item-price').value) || 0; + netto += qty * price; + }); + const disc = Math.min(100, Math.max(0, parseFloat(discountEl?.value) || 0)); + const rabatt = netto * disc / 100; + const brutto = netto - rabatt; + previewEl.innerHTML = ` + Netto: + ${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} € + ${disc > 0 ? ` · -${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)` : ''} +  · Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} € + `; + } + + // Erste Position hinzufügen + _addItem('Ban Yaro Pro Jahresabo', 1, 29.00); + + // Weitere Position + document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem()); + discountEl?.addEventListener('input', _updatePreview); + + // Form Submit + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const items = []; + itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { + const desc = row.querySelector('.inv-item-desc').value.trim(); + const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 1; + const price = parseFloat(row.querySelector('.inv-item-price').value) || 0; + if (desc) items.push({ description: desc, quantity: qty, unit_price: price }); + }); + if (!items.length) { UI.toast.warning('Mindestens eine Position angeben.'); return; } + + const submitBtn = e.target.closest('.modal-content, [id]') + ? document.querySelector(`button[form="${id}"]`) + : null; + if (submitBtn) submitBtn.disabled = true; + + try { + await API.post('/admin/invoices', { + recipient_name: fd.get('recipient_name'), + recipient_email: fd.get('recipient_email') || null, + recipient_address: fd.get('recipient_address') || null, + service_period: fd.get('service_period') || null, + discount_pct: parseFloat(fd.get('discount_pct')) || 0, + notes: fd.get('notes') || null, + items, + }); + UI.modal.close(); + UI.toast.success('Rechnung erstellt.'); + reload(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Erstellen.'); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + function _openBezahltModal(invoiceId, defaultAmount, reload) { + const today = new Date().toISOString().slice(0, 10); + const id = `inv-pay-${Date.now()}`; + + UI.modal.open({ + title: `${UI.icon('check-circle')} Als bezahlt markieren`, + body: ` +
+
+ + +
+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const submitBtn = document.querySelector(`button[form="${id}"]`); + if (submitBtn) submitBtn.disabled = true; + try { + await API.post(`/admin/invoices/${invoiceId}/pay`, { + paid_at: fd.get('paid_at'), + paid_amount: parseFloat(fd.get('paid_amount')), + }); + UI.modal.close(); + UI.toast.success('Rechnung als bezahlt markiert.'); + reload(); + } catch (err) { + UI.toast.error(err.message || 'Fehler.'); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + function _openStornoModal(invoiceId, invoiceNum, reload) { + const id = `inv-cancel-${Date.now()}`; + + UI.modal.open({ + title: `${UI.icon('x-circle')} Rechnung stornieren`, + body: ` +
+

+ Rechnung ${_esc(invoiceNum)} stornieren. +

+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const reason = (fd.get('reason') || '').trim(); + if (!reason) { UI.toast.warning('Bitte einen Grund angeben.'); return; } + const submitBtn = document.querySelector(`button[form="${id}"]`); + if (submitBtn) submitBtn.disabled = true; + try { + await API.post(`/admin/invoices/${invoiceId}/cancel`, { reason }); + UI.modal.close(); + UI.toast.success('Rechnung storniert.'); + reload(); + } catch (err) { + UI.toast.error(err.message || 'Fehler.'); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + async function _openDetailModal(invoiceId) { + let inv; + try { + inv = await API.get(`/admin/invoices/${invoiceId}`); + } catch (e) { + UI.toast.error(e.message || 'Detail konnte nicht geladen werden.'); + return; + } + + const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—'; + const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; + + const statusColors = { + draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', + paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)', + }; + const statusLabels = { draft: 'Entwurf', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' }; + + const itemsHtml = (inv.items || []).map(item => ` + + ${_esc(item.description)} + ${item.quantity} + ${_fmtEur(item.unit_price)} + ${_fmtEur(item.total)} + + `).join(''); + + UI.modal.open({ + title: `${UI.icon('receipt')} ${_esc(inv.invoice_number)}`, + body: ` +
+
+
+
Empfänger
+
${_esc(inv.recipient_name)}
+ ${inv.recipient_email ? `
${_esc(inv.recipient_email)}
` : ''} + ${inv.recipient_address ? `
${_esc(inv.recipient_address)}
` : ''} +
+
+
Status
+
${statusLabels[inv.status] || inv.status}
+
+ Erstellt: ${_fmtDate(inv.created_at)}
+ ${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}
` : ''} + ${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}
` : ''} +
+
+
+ + ${inv.service_period ? ` +
+
Leistungszeitraum
+
${_esc(inv.service_period)}
+
` : ''} + + +
+
Positionen
+ + + + + + + + + + ${itemsHtml} + + + + + + +
BeschreibungMengePreisGesamt
Gesamt (brutto)${_fmtEur(inv.amount_gross)}
+
+ + ${inv.notes ? ` +
+
Notizen
+
${_esc(inv.notes)}
+
` : ''} +
+ `, + footer: ``, + }); + } + + async function _loadCashflow(el) { + let cf; + try { + cf = await API.get('/admin/invoices/cashflow'); + } catch (e) { + el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Cashflow konnte nicht geladen werden.'); + return; + } + + const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; + + const statusLabels = { draft: 'Entwürfe', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' }; + const statusColors = { draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)' }; + + const countKacheln = Object.entries(cf.counts || {}).map(([s, n]) => ` +
+
${n}
+
${statusLabels[s] || s}
+
`).join(''); + + const monthRows = (cf.monthly || []).map((m, i) => ` + + ${_esc(m.month)} + ${m.count} + ${_fmtEur(m.revenue)} + `).join(''); + + // Quartalsbericht-Download + const currentYear = new Date().getFullYear(); + const years = [currentYear, currentYear - 1].map(y => ``).join(''); + + el.innerHTML = ` + +
+
+
${_fmtEur(cf.total_paid)}
+
Einnahmen (bezahlt)
+
+
+
${_fmtEur(cf.total_outstanding)}
+
Offene Forderungen
+
+
+
${_fmtEur(cf.total_year)}
+
Jahresumsatz gesamt
+
+ ${countKacheln} +
+ + +
+
Monatliche Übersicht
+
+ + + + + + + + + + ${monthRows || ``} + +
MonatRechnungenUmsatz
Keine Daten
+
+
+ + +
+
+ ${UI.icon('file-csv')} Quartalsbericht herunterladen +
+
+
+ + +
+
+ + +
+ + +
+
+
+ `; + + // CSV Download + el.querySelector('#adm-inv-csv')?.addEventListener('click', async () => { + const year = el.querySelector('#adm-inv-year').value; + const q = el.querySelector('#adm-inv-quarter').value; + try { + const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`); + if (!data.invoices?.length) { UI.toast.warning('Keine Rechnungen in diesem Quartal.'); return; } + + // CSV generieren + const fmtEur = v => v != null ? Number(v).toFixed(2) : '0.00'; + const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : ''; + const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; + + const header = 'Nummer;Empfänger;E-Mail;Betrag (netto);Betrag (brutto);Status;Erstellt;Versendet;Bezahlt\n'; + const csvRows = data.invoices.map(inv => + [inv.invoice_number, inv.recipient_name, inv.recipient_email || '', + fmtEur(inv.amount_gross), fmtEur(inv.amount_gross), inv.status, + fmtDate(inv.created_at), fmtDate(inv.sent_at), fmtDate(inv.paid_at) + ].map(escape).join(';') + ).join('\n'); + + const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `banyaro-rechnungen-${year}-Q${q}.csv`; + a.click(); + URL.revokeObjectURL(url); + UI.toast.success(`CSV mit ${data.invoices.length} Rechnungen heruntergeladen.`); + } catch (e) { + UI.toast.error(e.message || 'Fehler beim Laden.'); + } + }); + + // Quartals-Vorschau + el.querySelector('#adm-inv-preview-q')?.addEventListener('click', async () => { + const year = el.querySelector('#adm-inv-year').value; + const q = el.querySelector('#adm-inv-quarter').value; + const resultEl = el.querySelector('#adm-inv-q-result'); + resultEl.innerHTML = '
Lade…
'; + try { + const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`); + if (!data.invoices?.length) { + resultEl.innerHTML = `
Keine Rechnungen in ${data.period || `Q${q} ${year}`}.
`; + return; + } + const _fmtE = v => v != null ? Number(v).toLocaleString('de-DE',{minimumFractionDigits:2}) + ' €' : '—'; + const _fmtD = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '—'; + const sL = { draft:'Entwurf',sent:'Versendet',paid:'Bezahlt',cancelled:'Storniert' }; + const rows2 = data.invoices.map((inv, i) => ` + + ${_esc(inv.invoice_number)} + ${_esc(inv.recipient_name)} + ${_fmtE(inv.amount_gross)} + ${sL[inv.status]||inv.status} + ${_fmtD(inv.created_at)} + `).join(''); + resultEl.innerHTML = ` +
+ ${_esc(data.period || `Q${q} ${year}`)} — ${data.count} Rechnung(en) · Brutto: ${_fmtE(data.total_gross)} +
+
+ + + + + + + ${rows2} + + + + + +
NummerEmpfängerBetragStatusErstellt
Gesamt${_fmtE(data.total_gross)} + Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)} +
+
`; + } catch (e) { + resultEl.innerHTML = `
Fehler: ${_esc(e.message)}
`; + } + }); + } + return { init, refresh, onDogChange }; })(); From b68a12587aba1fe4f057b2c0a3bc418a9b5fdb40 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 10:04:23 +0200 Subject: [PATCH 008/113] =?UTF-8?q?Feature:=20Rechnungs-System=20(invoices?= =?UTF-8?q?)=20=E2=80=94=20Backend=20komplett?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB-Migration: invoices + invoice_items Tabellen inkl. Indizes - routes/invoices.py: vollständiger Admin-Router (prefix /api/admin/invoices) - CRUD: Liste, Detail, Erstellen, Senden, Bezahlen, Stornieren - PDF-Generierung via fpdf2 mit §14-UStG-Pflichtangaben (Kleinunternehmer-Hinweis) - Cashflow-Übersicht und Quartalsbericht - PDF-Download-Endpunkt - Speicherung in /scaninput + optionaler Paperless-Upload - mailer.py: send_email() + Backends um optionale PDF-Anhänge erweitert (Brevo: base64, SMTP: MIMEApplication) - main.py: invoices_router registriert - docker-compose.yml: /volume1/scaninput:/scaninput Volume hinzugefügt --- backend/database.py | 46 ++++ backend/mailer.py | 42 +++- backend/main.py | 2 + backend/routes/invoices.py | 489 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 1 + 5 files changed, 570 insertions(+), 10 deletions(-) create mode 100644 backend/routes/invoices.py diff --git a/backend/database.py b/backend/database.py index f5aee7b..ec362bc 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2398,6 +2398,52 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration route_dogs fehlgeschlagen: {e}") + # Rechnungs-System + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_number TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id), + recipient_name TEXT NOT NULL, + recipient_email TEXT NOT NULL, + recipient_address TEXT, + description TEXT NOT NULL, + service_period TEXT, + amount_net REAL NOT NULL, + discount_pct REAL DEFAULT 0, + discount_amount REAL DEFAULT 0, + amount_after_discount REAL NOT NULL, + tax_rate REAL DEFAULT 0, + tax_amount REAL DEFAULT 0, + amount_gross REAL NOT NULL, + status TEXT DEFAULT 'draft', + notes TEXT, + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + sent_at TEXT, + paid_at TEXT, + paid_amount REAL, + cancelled_at TEXT, + cancellation_reason TEXT, + cancellation_number TEXT + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)") + conn.execute(""" + CREATE TABLE IF NOT EXISTS invoice_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + quantity REAL NOT NULL DEFAULT 1, + unit_price REAL NOT NULL, + total REAL NOT NULL + ) + """) + logger.info("Migration: invoices + invoice_items bereit.") + except Exception as e: + logger.warning(f"Migration invoices: {e}") + def _seed_help_articles(conn): """Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist.""" diff --git a/backend/mailer.py b/backend/mailer.py index 344fe4f..03d9228 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -6,11 +6,13 @@ Unterstützt zwei Backends (wird automatisch gewählt): """ import os +import base64 import smtplib import asyncio import logging from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from email.mime.application import MIMEApplication import httpx @@ -33,9 +35,7 @@ APP_URL = os.getenv("APP_URL", "https://banyaro.app") # ------------------------------------------------------------------ # Brevo REST-API # ------------------------------------------------------------------ -async def _send_brevo(to: str, subject: str, html: str, plain: str): - # Absender-Name und -Adresse aus SMTP_FROM parsen - # Format: "Ban Yaro " oder "noreply@banyaro.app" +async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: list | None = None): from_raw = SMTP_FROM if "<" in from_raw: from_name = from_raw[:from_raw.index("<")].strip() @@ -52,6 +52,14 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str): "textContent": plain, "headers": {"X-Mailin-Track-Click": "0", "X-Mailin-Track-Opens": "0"}, } + if attachments: + payload["attachment"] = [ + { + "name": a["filename"], + "content": base64.b64encode(a["content"]).decode("ascii"), + } + for a in attachments + ] async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( BREVO_API_URL, @@ -64,13 +72,25 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str): # ------------------------------------------------------------------ # SMTP Fallback # ------------------------------------------------------------------ -def _send_smtp_sync(to: str, subject: str, html: str, plain: str): - msg = MIMEMultipart("alternative") +def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: list | None = None): + if attachments: + msg = MIMEMultipart("mixed") + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(plain, "plain", "utf-8")) + alt.attach(MIMEText(html, "html", "utf-8")) + msg.attach(alt) + for a in attachments: + part = MIMEApplication(a["content"], Name=a["filename"]) + part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"' + msg.attach(part) + else: + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(plain, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) + msg["Subject"] = subject msg["From"] = SMTP_FROM msg["To"] = to - msg.attach(MIMEText(plain, "plain", "utf-8")) - msg.attach(MIMEText(html, "html", "utf-8")) with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s: s.ehlo() @@ -83,10 +103,10 @@ def _send_smtp_sync(to: str, subject: str, html: str, plain: str): # ------------------------------------------------------------------ # Öffentliche Funktion # ------------------------------------------------------------------ -async def send_email(to: str, subject: str, html: str, plain: str = ""): +async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None): if BREVO_API_KEY: try: - await _send_brevo(to, subject, html, plain) + await _send_brevo(to, subject, html, plain, attachments) logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}") return except Exception as e: @@ -96,7 +116,9 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""): if SMTP_HOST: loop = asyncio.get_event_loop() try: - await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain) + await loop.run_in_executor( + None, _send_smtp_sync, to, subject, html, plain, attachments + ) logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}") return except Exception as e: diff --git a/backend/main.py b/backend/main.py index 8f75144..338c427 100644 --- a/backend/main.py +++ b/backend/main.py @@ -253,6 +253,7 @@ from routes.challenges import router as challenges_router from routes.gassi_zeiten import router as gassi_zeiten_router from routes.help import router as help_router from routes.feedback import router as feedback_router +from routes.invoices import router as invoices_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -317,6 +318,7 @@ app.include_router(challenges_router, prefix="/api/challenges", ta app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"]) app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"]) +app.include_router(invoices_router) # ------------------------------------------------------------------ diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py new file mode 100644 index 0000000..2f53bae --- /dev/null +++ b/backend/routes/invoices.py @@ -0,0 +1,489 @@ +"""BAN YARO — Rechnungs-System (Admin)""" + +import os +import logging +from datetime import datetime +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel +from database import db +from auth import require_admin +import mailer + +router = APIRouter(prefix="/api/admin/invoices", tags=["invoices"]) +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class InvoiceItem(BaseModel): + description: str + quantity: float = 1.0 + unit_price: float + + +class InvoiceCreate(BaseModel): + user_id: Optional[int] = None + recipient_name: str + recipient_email: str + recipient_address: Optional[str] = None + items: List[InvoiceItem] + discount_pct: Optional[float] = 0.0 + service_period: Optional[str] = None + notes: Optional[str] = None + + +class PayBody(BaseModel): + paid_at: str + paid_amount: float + + +class CancelBody(BaseModel): + reason: str + + +# ------------------------------------------------------------------ +# Hilfsfunktionen +# ------------------------------------------------------------------ +def _next_invoice_number(conn, prefix="RG"): + year = datetime.now().year + last = conn.execute( + "SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1", + (f"{prefix}-{year}-%",) + ).fetchone() + if last: + n = int(last[0].split("-")[-1]) + 1 + else: + n = 1 + return f"{prefix}-{year}-{n:04d}" + + +def _generate_pdf(invoice, items) -> bytes: + KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true" + STEUERNUMMER = os.getenv("STEUERNUMMER", "") + INHABER = os.getenv("RECHNUNG_INHABER", "René Degelmann") + GESCHAEFTSNAME = os.getenv("RECHNUNG_GESCHAEFTSNAME", "Ban Yaro") + STRASSE = os.getenv("RECHNUNG_STRASSE", "") + PLZ_ORT = os.getenv("RECHNUNG_PLZ_ORT", "") + EMAIL_ABSENDER = os.getenv("RECHNUNG_EMAIL", "hallo@banyaro.app") + BANK_IBAN = os.getenv("RECHNUNG_IBAN", "") + BANK_BIC = os.getenv("RECHNUNG_BIC", "") + BANK_BANK = os.getenv("RECHNUNG_BANK", "") + + from fpdf import FPDF + pdf = FPDF() + pdf.add_page() + pdf.set_margins(20, 20, 20) + pdf.set_auto_page_break(auto=True, margin=20) + + pdf.set_font("Helvetica", "B", 18) + pdf.cell(0, 10, GESCHAEFTSNAME, new_x="LMARGIN", new_y="NEXT") + pdf.set_font("Helvetica", "", 10) + pdf.cell(0, 5, INHABER, new_x="LMARGIN", new_y="NEXT") + if STRASSE: + pdf.cell(0, 5, STRASSE, new_x="LMARGIN", new_y="NEXT") + if PLZ_ORT: + pdf.cell(0, 5, PLZ_ORT, new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 5, EMAIL_ABSENDER, new_x="LMARGIN", new_y="NEXT") + pdf.ln(10) + + pdf.set_font("Helvetica", "B", 10) + pdf.cell(0, 5, "Rechnungsempfänger:", new_x="LMARGIN", new_y="NEXT") + pdf.set_font("Helvetica", "", 10) + pdf.cell(0, 5, invoice["recipient_name"], new_x="LMARGIN", new_y="NEXT") + if invoice["recipient_address"]: + for line in invoice["recipient_address"].split("\n"): + pdf.cell(0, 5, line, new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 5, invoice["recipient_email"], new_x="LMARGIN", new_y="NEXT") + pdf.ln(8) + + pdf.set_font("Helvetica", "B", 10) + pdf.cell(0, 5, f"Rechnungsnummer: {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT") + pdf.set_font("Helvetica", "", 10) + rg_date = invoice["created_at"][:10] if invoice["created_at"] else "" + pdf.cell(0, 5, f"Rechnungsdatum: {rg_date}", new_x="LMARGIN", new_y="NEXT") + if invoice["service_period"]: + pdf.cell(0, 5, f"Leistungszeitraum: {invoice['service_period']}", new_x="LMARGIN", new_y="NEXT") + if STEUERNUMMER: + pdf.cell(0, 5, f"Steuernummer: {STEUERNUMMER}", new_x="LMARGIN", new_y="NEXT") + pdf.ln(8) + + pdf.set_font("Helvetica", "B", 14) + pdf.cell(0, 10, "RECHNUNG", new_x="LMARGIN", new_y="NEXT") + pdf.ln(4) + + pdf.set_fill_color(240, 240, 240) + pdf.set_font("Helvetica", "B", 9) + pdf.cell(90, 7, "Beschreibung", border=1, fill=True) + pdf.cell(20, 7, "Menge", border=1, fill=True, align="C") + pdf.cell(35, 7, "Einzelpreis", border=1, fill=True, align="R") + pdf.cell(35, 7, "Gesamt", border=1, fill=True, align="R", new_x="LMARGIN", new_y="NEXT") + + pdf.set_font("Helvetica", "", 9) + for item in items: + qty_str = f"{item['quantity']:.2f}".rstrip("0").rstrip(".") + pdf.cell(90, 7, str(item["description"])[:60], border=1) + pdf.cell(20, 7, qty_str, border=1, align="C") + pdf.cell(35, 7, f"{item['unit_price']:.2f} EUR", border=1, align="R") + pdf.cell(35, 7, f"{item['total']:.2f} EUR", border=1, align="R", new_x="LMARGIN", new_y="NEXT") + + pdf.ln(4) + + def right_row(label, value, bold=False): + pdf.set_font("Helvetica", "B" if bold else "", 10) + pdf.cell(120, 6, "") + pdf.cell(40, 6, label, align="R") + pdf.cell(10, 6, "") + pdf.cell(0, 6, value, align="R", new_x="LMARGIN", new_y="NEXT") + + right_row("Nettobetrag:", f"{invoice['amount_net']:.2f} EUR") + if invoice["discount_pct"] and invoice["discount_pct"] > 0: + right_row(f"Rabatt ({invoice['discount_pct']:.0f}%):", f"-{invoice['discount_amount']:.2f} EUR") + right_row("Nach Rabatt:", f"{invoice['amount_after_discount']:.2f} EUR") + + if not KLEINUNTERNEHMER: + right_row(f"MwSt. {invoice['tax_rate']:.0f}%:", f"{invoice['tax_amount']:.2f} EUR") + + pdf.ln(2) + pdf.set_draw_color(0, 0, 0) + right_row("Gesamtbetrag:", f"{invoice['amount_gross']:.2f} EUR", bold=True) + + pdf.ln(8) + + if KLEINUNTERNEHMER: + pdf.set_font("Helvetica", "I", 9) + pdf.multi_cell(0, 5, "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.") + pdf.ln(4) + + if BANK_IBAN: + pdf.set_font("Helvetica", "", 9) + pdf.cell(0, 5, "Bitte überweisen Sie den Betrag innerhalb von 14 Tagen auf:", new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 5, f"IBAN: {BANK_IBAN}", new_x="LMARGIN", new_y="NEXT") + if BANK_BIC: + pdf.cell(0, 5, f"BIC: {BANK_BIC}", new_x="LMARGIN", new_y="NEXT") + if BANK_BANK: + pdf.cell(0, 5, f"Bank: {BANK_BANK}", new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 5, f"Verwendungszweck: {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT") + + if invoice["notes"]: + pdf.ln(4) + pdf.set_font("Helvetica", "I", 9) + pdf.multi_cell(0, 5, invoice["notes"]) + + return bytes(pdf.output()) + + +async def _save_to_paperless(pdf_bytes: bytes, invoice_number: str, filename: str): + scaninput = os.getenv("SCANINPUT_DIR", "/scaninput") + os.makedirs(scaninput, exist_ok=True) + path = os.path.join(scaninput, filename) + with open(path, "wb") as f: + f.write(pdf_bytes) + + paperless_url = os.getenv("PAPERLESS_URL", "") + paperless_token = os.getenv("PAPERLESS_TOKEN", "") + if paperless_url and paperless_token: + try: + import httpx + async with httpx.AsyncClient(timeout=30) as client: + await client.post( + f"{paperless_url}/api/documents/post_document/", + headers={"Authorization": f"Token {paperless_token}"}, + files={"document": (filename, pdf_bytes, "application/pdf")}, + data={"title": invoice_number, "tags": "banyaro,Rechnung"}, + ) + except Exception as e: + logger.warning(f"Paperless upload failed: {e}") + + +def _row_to_dict(row) -> dict: + return dict(row) + + +def _fetch_items(conn, invoice_id: int) -> list: + rows = conn.execute( + "SELECT * FROM invoice_items WHERE invoice_id=? ORDER BY id", + (invoice_id,) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# Endpoints +# ------------------------------------------------------------------ +@router.get("") +def list_invoices(status: Optional[str] = None, admin=Depends(require_admin)): + with db() as conn: + if status: + rows = conn.execute( + "SELECT * FROM invoices WHERE status=? ORDER BY id DESC", + (status,) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM invoices ORDER BY id DESC" + ).fetchall() + return [_row_to_dict(r) for r in rows] + + +@router.get("/cashflow") +def get_cashflow(admin=Depends(require_admin)): + with db() as conn: + monthly = conn.execute(""" + SELECT substr(created_at, 1, 7) AS month, + SUM(amount_gross) AS revenue, + COUNT(*) AS count + FROM invoices + WHERE status IN ('sent', 'paid') + GROUP BY month + ORDER BY month DESC + """).fetchall() + + year = datetime.now().year + total_year = conn.execute( + "SELECT COALESCE(SUM(amount_gross),0) FROM invoices WHERE status IN ('sent','paid') AND created_at LIKE ?", + (f"{year}%",) + ).fetchone()[0] + + total_outstanding = conn.execute( + "SELECT COALESCE(SUM(amount_gross),0) FROM invoices WHERE status='sent'" + ).fetchone()[0] + + total_paid = conn.execute( + "SELECT COALESCE(SUM(COALESCE(paid_amount, amount_gross)),0) FROM invoices WHERE status='paid'" + ).fetchone()[0] + + counts_rows = conn.execute( + "SELECT status, COUNT(*) AS n FROM invoices GROUP BY status" + ).fetchall() + + counts = {r["status"]: r["n"] for r in counts_rows} + return { + "monthly": [_row_to_dict(r) for r in monthly], + "total_year": round(total_year, 2), + "total_outstanding": round(total_outstanding, 2), + "total_paid": round(total_paid, 2), + "counts": counts, + } + + +@router.get("/quarterly/{year}/{q}") +def get_quarterly(year: int, q: int, admin=Depends(require_admin)): + if q not in (1, 2, 3, 4): + raise HTTPException(400, "Quartal muss 1–4 sein.") + month_start = (q - 1) * 3 + 1 + month_end = month_start + 2 + from_date = f"{year}-{month_start:02d}-01" + import calendar + last_day = calendar.monthrange(year, month_end)[1] + to_date = f"{year}-{month_end:02d}-{last_day:02d}" + + labels = {1: "01.01.", 2: "01.04.", 3: "01.07.", 4: "01.10."} + ends = {1: "31.03.", 2: "30.06.", 3: "30.09.", 4: "31.12."} + period = f"Q{q} {year} ({labels[q]} – {ends[q]})" + + with db() as conn: + rows = conn.execute( + "SELECT * FROM invoices WHERE created_at >= ? AND created_at <= ? ORDER BY id", + (from_date, to_date + "T23:59:59Z") + ).fetchall() + + total_net = sum(r["amount_net"] for r in rows) + total_tax = sum(r["tax_amount"] for r in rows) + total_gross = sum(r["amount_gross"] for r in rows) + + return { + "period": period, + "invoices": [_row_to_dict(r) for r in rows], + "total_net": round(total_net, 2), + "total_tax": round(total_tax, 2), + "total_gross": round(total_gross, 2), + "count": len(rows), + } + + +@router.get("/{invoice_id}") +def get_invoice(invoice_id: int, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + if not row: + raise HTTPException(404, "Rechnung nicht gefunden.") + items = _fetch_items(conn, invoice_id) + result = _row_to_dict(row) + result["items"] = items + return result + + +@router.post("", status_code=201) +def create_invoice(data: InvoiceCreate, admin=Depends(require_admin)): + if not data.items: + raise HTTPException(400, "Mindestens eine Position erforderlich.") + + KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true" + TAX_RATE = 0.0 if KLEINUNTERNEHMER else float(os.getenv("RECHNUNG_MWST", "19")) + + amount_net = round(sum(i.quantity * i.unit_price for i in data.items), 2) + discount_pct = data.discount_pct or 0.0 + discount_amount = round(amount_net * discount_pct / 100, 2) + amount_after_discount = round(amount_net - discount_amount, 2) + tax_amount = round(amount_after_discount * TAX_RATE / 100, 2) + amount_gross = round(amount_after_discount + tax_amount, 2) + + description = data.items[0].description if len(data.items) == 1 else f"{len(data.items)} Positionen" + + with db() as conn: + invoice_number = _next_invoice_number(conn) + conn.execute(""" + INSERT INTO invoices + (invoice_number, user_id, recipient_name, recipient_email, recipient_address, + description, service_period, amount_net, discount_pct, discount_amount, + amount_after_discount, tax_rate, tax_amount, amount_gross, notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + invoice_number, data.user_id, data.recipient_name, data.recipient_email, + data.recipient_address, description, data.service_period, + amount_net, discount_pct, discount_amount, + amount_after_discount, TAX_RATE, tax_amount, amount_gross, + data.notes, + )) + invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + + for item in data.items: + total = round(item.quantity * item.unit_price, 2) + conn.execute( + "INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)", + (invoice_id, item.description, item.quantity, item.unit_price, total) + ) + + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + items = _fetch_items(conn, invoice_id) + + result = _row_to_dict(row) + result["items"] = items + return result + + +@router.post("/{invoice_id}/send") +async def send_invoice(invoice_id: int, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + if not row: + raise HTTPException(404, "Rechnung nicht gefunden.") + if row["status"] == "cancelled": + raise HTTPException(400, "Stornierte Rechnung kann nicht gesendet werden.") + items = _fetch_items(conn, invoice_id) + + invoice = _row_to_dict(row) + + try: + pdf_bytes = _generate_pdf(invoice, items) + except Exception as e: + logger.error(f"PDF-Generierung fehlgeschlagen: {e}") + raise HTTPException(500, f"PDF-Generierung fehlgeschlagen: {e}") + + filename = f"{invoice['invoice_number']}_banyaro.pdf" + + try: + await _save_to_paperless(pdf_bytes, invoice["invoice_number"], filename) + except Exception as e: + logger.warning(f"Paperless-Speicherung fehlgeschlagen: {e}") + + import base64 + body_html = f""" +

Hallo {invoice['recipient_name']},

+

+ anbei erhalten Sie Ihre Rechnung {invoice['invoice_number']} + über {invoice['amount_gross']:.2f} EUR. +

+

Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.

+

Verwendungszweck: {invoice['invoice_number']}

+ """ + html = mailer.email_html(body_html) + plain = ( + f"Hallo {invoice['recipient_name']},\n\n" + f"anbei Ihre Rechnung {invoice['invoice_number']} über {invoice['amount_gross']:.2f} EUR.\n\n" + f"Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.\n" + f"Verwendungszweck: {invoice['invoice_number']}\n" + ) + + attachments = [{ + "filename": filename, + "content": pdf_bytes, + "content_type": "application/pdf", + }] + + try: + await mailer.send_email( + to=invoice["recipient_email"], + subject=f"Ihre Rechnung {invoice['invoice_number']} von Ban Yaro", + html=html, + plain=plain, + attachments=attachments, + ) + except Exception as e: + logger.error(f"Mail-Versand fehlgeschlagen: {e}") + raise HTTPException(500, f"Mail-Versand fehlgeschlagen: {e}") + + now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z") + with db() as conn: + conn.execute( + "UPDATE invoices SET status='sent', sent_at=? WHERE id=?", + (now, invoice_id) + ) + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + + return _row_to_dict(row) + + +@router.get("/{invoice_id}/pdf") +def download_pdf(invoice_id: int, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + if not row: + raise HTTPException(404, "Rechnung nicht gefunden.") + items = _fetch_items(conn, invoice_id) + + invoice = _row_to_dict(row) + pdf_bytes = _generate_pdf(invoice, items) + filename = f"{invoice['invoice_number']}_banyaro.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post("/{invoice_id}/pay") +def pay_invoice(invoice_id: int, data: PayBody, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + if not row: + raise HTTPException(404, "Rechnung nicht gefunden.") + if row["status"] == "cancelled": + raise HTTPException(400, "Stornierte Rechnung kann nicht als bezahlt markiert werden.") + conn.execute( + "UPDATE invoices SET status='paid', paid_at=?, paid_amount=? WHERE id=?", + (data.paid_at, data.paid_amount, invoice_id) + ) + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + return _row_to_dict(row) + + +@router.post("/{invoice_id}/cancel") +def cancel_invoice(invoice_id: int, data: CancelBody, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + if not row: + raise HTTPException(404, "Rechnung nicht gefunden.") + if row["status"] == "cancelled": + raise HTTPException(400, "Rechnung ist bereits storniert.") + cancellation_number = _next_invoice_number(conn, "ST") + now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z") + conn.execute( + "UPDATE invoices SET status='cancelled', cancelled_at=?, cancellation_reason=?, cancellation_number=? WHERE id=?", + (now, data.reason, cancellation_number, invoice_id) + ) + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + return _row_to_dict(row) diff --git a/docker-compose.yml b/docker-compose.yml index 772bb22..019c40d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - "3010:8000" # DS-intern, NPM leitet banyaro.app weiter volumes: - ./data:/data # SQLite + Media persistent + - /volume1/scaninput:/scaninput env_file: - .env environment: From 77093774f92a5bd19a53daadf73fd7750571b5b4 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 10:04:46 +0200 Subject: [PATCH 009/113] =?UTF-8?q?Feat:=20Admin-Rechnungs-Endpoints=20?= =?UTF-8?q?=E2=80=94=20invoices=5Funpaid,=20CSV-Download,=20Quartalsberich?= =?UTF-8?q?t-Versand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - action_items(): invoices_unpaid (status='sent') zum Return-Dict hinzugefügt - GET /api/admin/invoices/quarterly/{year}/{q}/csv — CSV-Download (Semikolon-getrennt, UTF-8-BOM für Excel) - POST /api/admin/invoices/send-quarterly-report — sendet CSV-Anhang an Steuerberater + Zusammenfassung an René (SMTP_FROM); graceful fallback wenn attachments noch nicht von mailer.py unterstützt --- backend/routes/admin.py | 206 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 205 insertions(+), 1 deletion(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index a8fa045..d12f9eb 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1,5 +1,8 @@ """BAN YARO — Admin / Moderator Backend""" import asyncio +import csv +import io +import logging import os import sys import time @@ -8,11 +11,14 @@ from datetime import datetime from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response from pydantic import BaseModel -from typing import Optional +from typing import Optional, List from database import db, DB_PATH from auth import get_current_user +logger = logging.getLogger(__name__) + router = APIRouter() _TZ = ZoneInfo("Europe/Berlin") @@ -83,6 +89,11 @@ def require_admin(user=Depends(get_current_user)): # ------------------------------------------------------------------ _VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"} +class QuarterlyReportBody(BaseModel): + year: int + quarter: int + email: str + class UserPatch(BaseModel): rolle: Optional[str] = None # user | moderator | admin is_moderator: Optional[int] = None @@ -130,6 +141,12 @@ async def action_items(user=Depends(require_mod)): ).fetchone()[0] except Exception: upgrades_pending = 0 + try: + invoices_unpaid = conn.execute( + "SELECT COUNT(*) FROM invoices WHERE status='sent'" + ).fetchone()[0] + except Exception: + invoices_unpaid = 0 return { "jobs_pending": jobs, "breeder_pending": breeders, @@ -138,6 +155,7 @@ async def action_items(user=Depends(require_mod)): "poi_edits_pending": poi_edits, "users_today": users_today, "upgrades_pending": upgrades_pending, + "invoices_unpaid": invoices_unpaid, } @@ -1242,3 +1260,189 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)): logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}") return {"ok": True, "tier": req["tier"], "user": req["name"]} + + +# ------------------------------------------------------------------ +# Helpers: Quartalsdaten +# ------------------------------------------------------------------ +def _quarter_bounds(year: int, q: int): + """Gibt (start_date, end_date) als ISO-Strings zurück (YYYY-MM-DD).""" + if q not in (1, 2, 3, 4): + raise HTTPException(400, "Quartal muss 1–4 sein.") + month_start = (q - 1) * 3 + 1 + month_end = month_start + 2 + # Letzter Tag des Endmonats + import calendar + last_day = calendar.monthrange(year, month_end)[1] + return ( + f"{year:04d}-{month_start:02d}-01", + f"{year:04d}-{month_end:02d}-{last_day:02d}", + ) + + +def _fetch_quarter_invoices(conn, year: int, q: int): + """Liest alle bezahlten/gesendeten Rechnungen des Quartals.""" + start, end = _quarter_bounds(year, q) + rows = conn.execute(""" + SELECT invoice_number, created_at, recipient_name, recipient_email, + amount_net, tax_amount, amount_gross, + status, paid_at, paid_amount + FROM invoices + WHERE status IN ('paid', 'sent') + AND DATE(created_at) BETWEEN ? AND ? + ORDER BY created_at ASC + """, (start, end)).fetchall() + return rows, start, end + + +def _build_csv(rows) -> bytes: + """Erstellt CSV-Bytes aus den Rechnungszeilen.""" + buf = io.StringIO() + writer = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_MINIMAL) + writer.writerow([ + "Rechnungsnummer", "Datum", "Empfänger", "E-Mail", + "Nettobetrag", "Steuer", "Bruttobetrag", + "Status", "Bezahlt-am", "Gezahlter-Betrag", + ]) + for r in rows: + # Datum auf YYYY-MM-DD kürzen + datum = (r["created_at"] or "")[:10] + paid_at = (r["paid_at"] or "")[:10] if r["paid_at"] else "" + writer.writerow([ + r["invoice_number"], + datum, + r["recipient_name"], + r["recipient_email"], + f"{r['amount_net']:.2f}".replace(".", ","), + f"{r['tax_amount']:.2f}".replace(".", ","), + f"{r['amount_gross']:.2f}".replace(".", ","), + r["status"], + paid_at, + f"{r['paid_amount']:.2f}".replace(".", ",") if r["paid_amount"] is not None else "", + ]) + return buf.getvalue().encode("utf-8-sig") # BOM für Excel-Kompatibilität + + +# ------------------------------------------------------------------ +# GET /api/admin/invoices/quarterly/{year}/{q}/csv +# ------------------------------------------------------------------ +@router.get("/invoices/quarterly/{year}/{q}/csv") +async def invoice_quarterly_csv(year: int, q: int, user=Depends(require_admin)): + """CSV-Download aller Rechnungen eines Quartals (paid + sent).""" + with db() as conn: + rows, start, end = _fetch_quarter_invoices(conn, year, q) + + csv_bytes = _build_csv(rows) + filename = f"rechnungen_{year}_Q{q}.csv" + logger.info(f"CSV-Download Q{q}/{year}: {len(rows)} Rechnungen → {filename}") + return Response( + content=csv_bytes, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# ------------------------------------------------------------------ +# POST /api/admin/invoices/send-quarterly-report +# ------------------------------------------------------------------ +@router.post("/invoices/send-quarterly-report") +async def send_quarterly_report(data: QuarterlyReportBody, user=Depends(require_admin)): + """Sendet Quartalsbericht als CSV-Anhang an Steuerberater + Zusammenfassung an René.""" + if data.quarter not in (1, 2, 3, 4): + raise HTTPException(400, "Quartal muss 1–4 sein.") + + with db() as conn: + rows, start, end = _fetch_quarter_invoices(conn, data.year, data.quarter) + + csv_bytes = _build_csv(rows) + filename = f"rechnungen_{data.year}_Q{data.quarter}.csv" + + # Zusammenfassungs-Zahlen + total_net = sum(r["amount_net"] for r in rows) + total_tax = sum(r["tax_amount"] for r in rows) + total_gross = sum(r["amount_gross"] for r in rows) + count_paid = sum(1 for r in rows if r["status"] == "paid") + count_sent = sum(1 for r in rows if r["status"] == "sent") + + subject_stb = ( + f"Ban Yaro – Rechnungen Q{data.quarter}/{data.year} " + f"({start} bis {end})" + ) + body_stb = ( + f"Hallo,\n\n" + f"anbei die Rechnungsübersicht für Q{data.quarter}/{data.year} " + f"({start} bis {end}).\n\n" + f"Anzahl Rechnungen: {len(rows)}\n" + f" davon bezahlt: {count_paid}\n" + f" davon ausstehend: {count_sent}\n\n" + f"Summe Netto: {total_net:>10.2f} EUR\n" + f"Summe Steuer: {total_tax:>10.2f} EUR\n" + f"Summe Brutto: {total_gross:>10.2f} EUR\n\n" + f"Die vollständige Liste finden Sie als CSV-Anhang.\n\n" + f"Viele Grüße\nRené Nitzsche / Ban Yaro" + ) + + from mailer import send_email, SMTP_FROM + + # Steuerberater-Mail (mit CSV-Anhang wenn unterstützt) + try: + await send_email( + data.email, + subject_stb, + f"
{body_stb}
", + body_stb, + attachments=[{ + "filename": filename, + "content": csv_bytes, + "content_type": "text/csv", + }], + ) + logger.info(f"Quartalsbericht Q{data.quarter}/{data.year} → {data.email} (mit Anhang)") + except TypeError: + # send_email unterstützt noch kein attachments-Argument → ohne Anhang senden + await send_email( + data.email, + subject_stb, + f"
{body_stb}
", + body_stb, + ) + logger.warning(f"Quartalsbericht Q{data.quarter}/{data.year} → {data.email} (OHNE Anhang, attachments nicht unterstützt)") + + # Zusammenfassung an René (SMTP_FROM-Adresse) + # Reine E-Mail-Adresse aus "Name " extrahieren + from_addr = SMTP_FROM + if "<" in from_addr: + from_addr = from_addr[from_addr.index("<") + 1 : from_addr.index(">")].strip() + + subject_rene = f"[Ban Yaro Admin] Quartalsbericht Q{data.quarter}/{data.year} versendet" + body_rene = ( + f"Der Quartalsbericht Q{data.quarter}/{data.year} wurde an {data.email} gesendet.\n\n" + f"Zeitraum: {start} bis {end}\n" + f"Rechnungen gesamt: {len(rows)} (bezahlt: {count_paid}, ausstehend: {count_sent})\n\n" + f"Netto: {total_net:>10.2f} EUR\n" + f"Steuer: {total_tax:>10.2f} EUR\n" + f"Brutto: {total_gross:>10.2f} EUR\n" + ) + try: + await send_email( + from_addr, + subject_rene, + f"
{body_rene}
", + body_rene, + ) + except Exception as e: + logger.warning(f"Zusammenfassungs-Mail an René fehlgeschlagen: {e}") + + return { + "ok": True, + "sent_to": data.email, + "year": data.year, + "quarter": data.quarter, + "period": f"{start} – {end}", + "count": len(rows), + "count_paid": count_paid, + "count_sent": count_sent, + "total_net": round(total_net, 2), + "total_tax": round(total_tax, 2), + "total_gross": round(total_gross, 2), + } From fa513be7f53e4c4849f7c164a2a2ec7d4a7cb154 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 10:07:10 +0200 Subject: [PATCH 010/113] =?UTF-8?q?Release=20v1.6.0=20=E2=80=94=20Rechnung?= =?UTF-8?q?s-System=20(SW=20by-v966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rechnungserstellung mit fortlaufender Nr. RG-YYYY-NNNN - PDF-Generierung (fpdf2) mit §14-UStG-Pflichtangaben - Kleinunternehmer-Modus (§19 UStG) via ENV - Mail-Versand mit PDF-Anhang (Brevo/SMTP) - Speicherung in /scaninput + Paperless-ngx REST-API - Admin-Panel: Invoice-Tab, Pay/Cancel-Modals, Cashflow - Quartalsbericht als CSV-Download + Mail-Versand - Action-Items: offene Rechnungen im To-Do-Band - Stornonummern ST-YYYY-NNNN - docker-compose: /volume1/scaninput:/scaninput --- backend/main.py | 2 +- backend/static/js/app.js | 4 ++-- backend/static/sw.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index 338c427..3d79bb8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "965" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "966" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3555d46..30f3537 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,8 +3,8 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '965'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '966'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen if (location.search.includes('_t=')) history.replaceState(null, '', '/'); diff --git a/backend/static/sw.js b/backend/static/sw.js index 97540a7..2293f58 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v965'; +const CACHE_VERSION = 'by-v966'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 95b70d51194b2ef926ba75d974497612db2e6887 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 10:45:49 +0200 Subject: [PATCH 011/113] =?UTF-8?q?Fix:=20Rechnungs-PDF=20komplett=20neu?= =?UTF-8?q?=20=E2=80=94=20DIN-5008-Layout,=20=C3=9Cberlagerung=20behoben,?= =?UTF-8?q?=20Bankverbindung,=20Footer,=20Deutsche=20Formatierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/invoices.py | 296 ++++++++++++++++++++++++++----------- 1 file changed, 210 insertions(+), 86 deletions(-) diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 2f53bae..a59e231 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -61,116 +61,240 @@ def _next_invoice_number(conn, prefix="RG"): def _generate_pdf(invoice, items) -> bytes: + from fpdf import FPDF + from datetime import datetime, timedelta + KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true" STEUERNUMMER = os.getenv("STEUERNUMMER", "") - INHABER = os.getenv("RECHNUNG_INHABER", "René Degelmann") - GESCHAEFTSNAME = os.getenv("RECHNUNG_GESCHAEFTSNAME", "Ban Yaro") - STRASSE = os.getenv("RECHNUNG_STRASSE", "") - PLZ_ORT = os.getenv("RECHNUNG_PLZ_ORT", "") - EMAIL_ABSENDER = os.getenv("RECHNUNG_EMAIL", "hallo@banyaro.app") - BANK_IBAN = os.getenv("RECHNUNG_IBAN", "") - BANK_BIC = os.getenv("RECHNUNG_BIC", "") - BANK_BANK = os.getenv("RECHNUNG_BANK", "") + INHABER = os.getenv("RECHNUNG_INHABER", "René Degelmann") + FIRMA = os.getenv("RECHNUNG_GESCHAEFTSNAME", "Ban Yaro") + STRASSE = os.getenv("RECHNUNG_STRASSE", "") + PLZ_ORT = os.getenv("RECHNUNG_PLZ_ORT", "") + EMAIL = os.getenv("RECHNUNG_EMAIL", "hallo@banyaro.app") + WEBSITE = os.getenv("RECHNUNG_WEBSITE", "banyaro.app") + IBAN = os.getenv("RECHNUNG_IBAN", "") + BIC = os.getenv("RECHNUNG_BIC", "") + BANKNAME = os.getenv("RECHNUNG_BANK", "") + + OR = (230, 126, 34) # Ban Yaro Orange + DK = (30, 30, 30) # Dunkelgrau Text + GY = (130, 130, 130) # Grau + LG = (245, 245, 245) # Hellgrau Hintergrund + WH = (255, 255, 255) # Weiss + + def eur(v: float) -> str: + s = f"{v:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") + return f"{s} EUR" + + def fdate(iso: str) -> str: + try: + y, m, d = (iso or "")[:10].split("-") + return f"{d}.{m}.{y}" + except Exception: + return (iso or "")[:10] + + try: + due_date = (datetime.fromisoformat(invoice["created_at"][:10]) + timedelta(days=14)).strftime("%d.%m.%Y") + except Exception: + due_date = "" - from fpdf import FPDF pdf = FPDF() pdf.add_page() - pdf.set_margins(20, 20, 20) - pdf.set_auto_page_break(auto=True, margin=20) + pdf.set_margins(20, 15, 20) + pdf.set_auto_page_break(auto=True, margin=28) + W = 170 # nutzbareite Breite (210 - 20 - 20) - pdf.set_font("Helvetica", "B", 18) - pdf.cell(0, 10, GESCHAEFTSNAME, new_x="LMARGIN", new_y="NEXT") - pdf.set_font("Helvetica", "", 10) - pdf.cell(0, 5, INHABER, new_x="LMARGIN", new_y="NEXT") - if STRASSE: - pdf.cell(0, 5, STRASSE, new_x="LMARGIN", new_y="NEXT") - if PLZ_ORT: - pdf.cell(0, 5, PLZ_ORT, new_x="LMARGIN", new_y="NEXT") - pdf.cell(0, 5, EMAIL_ABSENDER, new_x="LMARGIN", new_y="NEXT") - pdf.ln(10) + # ── Orangener Balken oben ───────────────────────────────────── + pdf.set_fill_color(*OR) + pdf.rect(20, 15, W, 1.5, "F") + # ── Firmenname rechts oben ──────────────────────────────────── + pdf.set_xy(20, 20) + pdf.set_font("Helvetica", "B", 22) + pdf.set_text_color(*OR) + pdf.cell(W, 10, FIRMA, align="R", new_x="LMARGIN", new_y="NEXT") + + # ── Absenderadresse rechts, klein ───────────────────────────── + pdf.set_font("Helvetica", "", 8.5) + pdf.set_text_color(*GY) + for line in filter(None, [INHABER, STRASSE, PLZ_ORT, EMAIL, WEBSITE]): + pdf.set_x(20) + pdf.cell(W, 4.5, line, align="R", new_x="LMARGIN", new_y="NEXT") + + # ── Absenderzeile über Empfängerfeld (DIN 5008) ─────────────── + sender_ref = " · ".join(filter(None, [FIRMA, INHABER, STRASSE, PLZ_ORT])) + pdf.set_xy(20, 56) + pdf.set_font("Helvetica", "", 6.5) + pdf.set_text_color(*GY) + pdf.cell(85, 4, sender_ref) + pdf.set_draw_color(*GY) + pdf.set_line_width(0.15) + pdf.line(20, 60.5, 105, 60.5) + + # ── Empfänger (links, DIN-5008-Fensterfeld) ─────────────────── + pdf.set_xy(20, 63) pdf.set_font("Helvetica", "B", 10) - pdf.cell(0, 5, "Rechnungsempfänger:", new_x="LMARGIN", new_y="NEXT") + pdf.set_text_color(*DK) + pdf.cell(85, 5.5, invoice["recipient_name"], new_x="LMARGIN", new_y="NEXT") pdf.set_font("Helvetica", "", 10) - pdf.cell(0, 5, invoice["recipient_name"], new_x="LMARGIN", new_y="NEXT") - if invoice["recipient_address"]: - for line in invoice["recipient_address"].split("\n"): - pdf.cell(0, 5, line, new_x="LMARGIN", new_y="NEXT") - pdf.cell(0, 5, invoice["recipient_email"], new_x="LMARGIN", new_y="NEXT") - pdf.ln(8) + if invoice.get("recipient_address"): + for line in str(invoice["recipient_address"]).split("\n"): + if line.strip(): + pdf.set_x(20) + pdf.cell(85, 5, line.strip(), new_x="LMARGIN", new_y="NEXT") + pdf.set_x(20) + pdf.set_font("Helvetica", "", 8.5) + pdf.set_text_color(*GY) + pdf.cell(85, 5, invoice["recipient_email"]) - pdf.set_font("Helvetica", "B", 10) - pdf.cell(0, 5, f"Rechnungsnummer: {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT") - pdf.set_font("Helvetica", "", 10) - rg_date = invoice["created_at"][:10] if invoice["created_at"] else "" - pdf.cell(0, 5, f"Rechnungsdatum: {rg_date}", new_x="LMARGIN", new_y="NEXT") - if invoice["service_period"]: - pdf.cell(0, 5, f"Leistungszeitraum: {invoice['service_period']}", new_x="LMARGIN", new_y="NEXT") - if STEUERNUMMER: - pdf.cell(0, 5, f"Steuernummer: {STEUERNUMMER}", new_x="LMARGIN", new_y="NEXT") - pdf.ln(8) + # ── Info-Block rechts (auf Empfänger-Höhe) ──────────────────── + # x=110, label 35mm + wert 25mm = 60mm → endet bei 170mm ✓ + info_rows = [ + ("Rechnungsnummer", invoice["invoice_number"]), + ("Datum", fdate(invoice.get("created_at", ""))), + ("Faellig bis", due_date), + ] + if invoice.get("service_period"): + info_rows.append(("Leistungszeitraum", invoice["service_period"])) + y_info = 63 + for lbl, val in info_rows: + pdf.set_xy(110, y_info) + pdf.set_font("Helvetica", "", 8.5) + pdf.set_text_color(*GY) + pdf.cell(35, 5.5, lbl + ":") + pdf.set_font("Helvetica", "B", 8.5) + pdf.set_text_color(*DK) + pdf.cell(25, 5.5, val) + y_info += 6 + + # ── Betreff ─────────────────────────────────────────────────── + pdf.set_xy(20, 100) pdf.set_font("Helvetica", "B", 14) - pdf.cell(0, 10, "RECHNUNG", new_x="LMARGIN", new_y="NEXT") - pdf.ln(4) + pdf.set_text_color(*DK) + pdf.cell(W, 8, f"Rechnung {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT") + pdf.set_draw_color(*OR) + pdf.set_line_width(0.6) + pdf.line(20, pdf.get_y(), 190, pdf.get_y()) + pdf.ln(5) - pdf.set_fill_color(240, 240, 240) + # ── Positionen-Tabelle ──────────────────────────────────────── + # Spalten: 90 + 18 + 32 + 30 = 170mm ✓ + CW = (90, 18, 32, 30) + + pdf.set_fill_color(*OR) + pdf.set_text_color(*WH) pdf.set_font("Helvetica", "B", 9) - pdf.cell(90, 7, "Beschreibung", border=1, fill=True) - pdf.cell(20, 7, "Menge", border=1, fill=True, align="C") - pdf.cell(35, 7, "Einzelpreis", border=1, fill=True, align="R") - pdf.cell(35, 7, "Gesamt", border=1, fill=True, align="R", new_x="LMARGIN", new_y="NEXT") + pdf.cell(CW[0], 7, " Beschreibung", fill=True) + pdf.cell(CW[1], 7, "Menge", fill=True, align="C") + pdf.cell(CW[2], 7, "Einzelpreis", fill=True, align="R") + pdf.cell(CW[3], 7, "Gesamt", fill=True, align="R", new_x="LMARGIN", new_y="NEXT") + pdf.set_text_color(*DK) pdf.set_font("Helvetica", "", 9) - for item in items: - qty_str = f"{item['quantity']:.2f}".rstrip("0").rstrip(".") - pdf.cell(90, 7, str(item["description"])[:60], border=1) - pdf.cell(20, 7, qty_str, border=1, align="C") - pdf.cell(35, 7, f"{item['unit_price']:.2f} EUR", border=1, align="R") - pdf.cell(35, 7, f"{item['total']:.2f} EUR", border=1, align="R", new_x="LMARGIN", new_y="NEXT") + pdf.set_line_width(0.2) + pdf.set_draw_color(200, 200, 200) + for i, item in enumerate(items): + pdf.set_fill_color(*(LG if i % 2 == 0 else WH)) + qty = f"{item['quantity']:.2f}".rstrip("0").rstrip(".") + pdf.cell(CW[0], 7, f" {str(item['description'])[:64]}", border="B", fill=True) + pdf.cell(CW[1], 7, qty, border="B", fill=True, align="C") + pdf.cell(CW[2], 7, eur(item["unit_price"]), border="B", fill=True, align="R") + pdf.cell(CW[3], 7, eur(item["total"]), border="B", fill=True, align="R", + new_x="LMARGIN", new_y="NEXT") + pdf.ln(5) + + # ── Summenblock (rechtsbündig, x=110) ───────────────────────── + # x=110: label 50mm + wert 30mm = 80mm → endet bei 190mm ✓ + def srow(lbl, val, bold=False, txt_color=None, bg=None): + pdf.set_x(110) + pdf.set_fill_color(*(bg or WH)) + pdf.set_text_color(*(txt_color or DK)) + pdf.set_font("Helvetica", "B" if bold else "", 10 if bold else 9) + pdf.cell(50, 6.5, lbl, align="R", fill=bool(bg)) + pdf.cell(30, 6.5, val, align="R", fill=bool(bg), new_x="LMARGIN", new_y="NEXT") + + srow("Nettobetrag:", eur(invoice["amount_net"])) + + if invoice.get("discount_pct") and invoice["discount_pct"] > 0: + srow(f"Rabatt ({invoice['discount_pct']:.0f}%):", f"- {eur(invoice['discount_amount'])}", txt_color=OR) + srow("Nach Rabatt:", eur(invoice["amount_after_discount"])) + + if not KLEINUNTERNEHMER and invoice.get("tax_rate", 0) > 0: + srow(f"MwSt. {invoice['tax_rate']:.0f}%:", eur(invoice["tax_amount"])) + + pdf.set_draw_color(*OR) + pdf.set_line_width(0.5) + pdf.line(110, pdf.get_y(), 190, pdf.get_y()) + pdf.ln(1) + srow("Gesamtbetrag:", eur(invoice["amount_gross"]), bold=True, bg=LG) pdf.ln(4) - def right_row(label, value, bold=False): - pdf.set_font("Helvetica", "B" if bold else "", 10) - pdf.cell(120, 6, "") - pdf.cell(40, 6, label, align="R") - pdf.cell(10, 6, "") - pdf.cell(0, 6, value, align="R", new_x="LMARGIN", new_y="NEXT") - - right_row("Nettobetrag:", f"{invoice['amount_net']:.2f} EUR") - if invoice["discount_pct"] and invoice["discount_pct"] > 0: - right_row(f"Rabatt ({invoice['discount_pct']:.0f}%):", f"-{invoice['discount_amount']:.2f} EUR") - right_row("Nach Rabatt:", f"{invoice['amount_after_discount']:.2f} EUR") - - if not KLEINUNTERNEHMER: - right_row(f"MwSt. {invoice['tax_rate']:.0f}%:", f"{invoice['tax_amount']:.2f} EUR") - - pdf.ln(2) - pdf.set_draw_color(0, 0, 0) - right_row("Gesamtbetrag:", f"{invoice['amount_gross']:.2f} EUR", bold=True) - - pdf.ln(8) - + # ── Steuerhinweis / Kleinunternehmer ────────────────────────── if KLEINUNTERNEHMER: - pdf.set_font("Helvetica", "I", 9) - pdf.multi_cell(0, 5, "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.") - pdf.ln(4) + pdf.set_x(20) + pdf.set_font("Helvetica", "I", 8.5) + pdf.set_text_color(*GY) + pdf.multi_cell(W, 5, "Gem. § 19 UStG wird keine Umsatzsteuer berechnet.") + + # ── Zahlungsinfo-Box ────────────────────────────────────────── + if IBAN or due_date: + pdf.ln(6) + y_box = pdf.get_y() + + pdf.set_x(24) + pdf.set_font("Helvetica", "B", 9) + pdf.set_text_color(*OR) + pdf.cell(W - 4, 6, "Zahlungsinformationen", new_x="LMARGIN", new_y="NEXT") - if BANK_IBAN: pdf.set_font("Helvetica", "", 9) - pdf.cell(0, 5, "Bitte überweisen Sie den Betrag innerhalb von 14 Tagen auf:", new_x="LMARGIN", new_y="NEXT") - pdf.cell(0, 5, f"IBAN: {BANK_IBAN}", new_x="LMARGIN", new_y="NEXT") - if BANK_BIC: - pdf.cell(0, 5, f"BIC: {BANK_BIC}", new_x="LMARGIN", new_y="NEXT") - if BANK_BANK: - pdf.cell(0, 5, f"Bank: {BANK_BANK}", new_x="LMARGIN", new_y="NEXT") - pdf.cell(0, 5, f"Verwendungszweck: {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT") + pdf.set_text_color(*DK) + pay_rows = [] + if due_date: pay_rows.append(("Zahlbar bis:", due_date)) + if IBAN: pay_rows.append(("IBAN:", IBAN)) + if BIC: pay_rows.append(("BIC:", BIC)) + if BANKNAME: pay_rows.append(("Bank:", BANKNAME)) + pay_rows.append( ("Verwendungszweck:", invoice["invoice_number"])) - if invoice["notes"]: - pdf.ln(4) + for lbl, val in pay_rows: + pdf.set_x(24) + pdf.set_font("Helvetica", "", 9) + pdf.cell(45, 5.5, lbl) + pdf.set_font("Helvetica", "" if lbl == "Verwendungszweck:" else "B", 9) + pdf.cell(0, 5.5, val, new_x="LMARGIN", new_y="NEXT") + + # Linker oranger Akzentbalken + pdf.set_fill_color(*OR) + pdf.rect(20, y_box, 2, pdf.get_y() - y_box + 1, "F") + + # ── Notizen ─────────────────────────────────────────────────── + if invoice.get("notes"): + pdf.ln(5) + pdf.set_x(20) pdf.set_font("Helvetica", "I", 9) - pdf.multi_cell(0, 5, invoice["notes"]) + pdf.set_text_color(*GY) + pdf.multi_cell(W, 5, str(invoice["notes"])) + + # ── Footer mit Pflichtangaben ───────────────────────────────── + pdf.set_y(-18) + pdf.set_draw_color(*OR) + pdf.set_line_width(0.4) + pdf.line(20, pdf.get_y(), 190, pdf.get_y()) + pdf.ln(2) + + footer_parts = [FIRMA, INHABER] + if STEUERNUMMER: + footer_parts.append(f"Steuernr.: {STEUERNUMMER}") + if EMAIL: + footer_parts.append(EMAIL) + if WEBSITE: + footer_parts.append(WEBSITE) + + pdf.set_font("Helvetica", "", 7.5) + pdf.set_text_color(*GY) + pdf.set_x(20) + pdf.cell(W, 4, " | ".join(footer_parts), align="C") return bytes(pdf.output()) From 0a466ef6ce77a57cd5e42adfed1f0c8519b607db Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 10:59:12 +0200 Subject: [PATCH 012/113] =?UTF-8?q?Feat:=20Rechnungsadresse=20=E2=80=94=20?= =?UTF-8?q?Profil,=20Upgrade-Modal=20Hinweis,=20Rechnung-erstellen-Button?= =?UTF-8?q?=20in=20Upgrade-Cards=20(SW=20by-v967)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 6 +++ backend/main.py | 2 +- backend/routes/admin.py | 2 +- backend/routes/auth.py | 3 +- backend/routes/profile.py | 3 +- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 75 +++++++++++++++++++++++------ backend/static/js/pages/settings.js | 14 ++++++ backend/static/sw.js | 2 +- 9 files changed, 89 insertions(+), 20 deletions(-) diff --git a/backend/database.py b/backend/database.py index ec362bc..8ba722f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2444,6 +2444,12 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration invoices: {e}") + try: + conn.execute("ALTER TABLE users ADD COLUMN billing_address TEXT") + logger.info("Migration: billing_address bereit.") + except Exception: + pass + def _seed_help_articles(conn): """Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist.""" diff --git a/backend/main.py b/backend/main.py index 3d79bb8..853e8a4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "966" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "967" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/admin.py b/backend/routes/admin.py index d12f9eb..8eb07ba 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1137,7 +1137,7 @@ async def list_upgrade_requests(user=Depends(require_admin)): with db() as conn: rows = conn.execute(""" SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at, - u.name, u.email + u.name, u.email, u.billing_address FROM upgrade_requests r JOIN users u ON u.id = r.user_id ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 895c94c..46e6330 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -241,7 +241,8 @@ async def me(user=Depends(get_current_user)): is_founder, is_partner, founder_number, is_founder_pending, notes_ki_enabled, gassi_stunde_push, preferred_theme, subscription_tier, - subscription_expires_at, subscription_cancelled_at, needs_dog_selection + subscription_expires_at, subscription_cancelled_at, needs_dog_selection, + billing_address FROM users WHERE id=?""", (user["id"],) ).fetchone() diff --git a/backend/routes/profile.py b/backend/routes/profile.py index a6f3a5b..2dfe098 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -28,6 +28,7 @@ class ProfileUpdate(BaseModel): notes_ki_enabled: Optional[int] = None gassi_stunde_push: Optional[int] = None preferred_theme: Optional[str] = None + billing_address: Optional[str] = None def _load_user(user_id: int) -> dict: @@ -35,7 +36,7 @@ def _load_user(user_id: int) -> dict: row = conn.execute( """SELECT id, name, real_name, email, rolle, is_premium, email_verified, bio, wohnort, erfahrung, social_link, - profil_sichtbarkeit, avatar_url, created_at + profil_sichtbarkeit, avatar_url, created_at, billing_address FROM users WHERE id=?""", (user_id,) ).fetchone() diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 30f3537..b972a93 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '966'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '967'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index c519e48..fed5b2b 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3540,12 +3540,22 @@ window.Page_admin = (() => { ` : ''} - +
+ + +
`; // Erledigte als kompakte Tabellenzeilen @@ -3610,6 +3620,31 @@ window.Page_admin = (() => { } }); }); + + // "Rechnung erstellen" — öffnet Invoice-Modal mit vorbefüllten Nutzerdaten + const TIER_ITEMS = { + pro: { description: 'Ban Yaro Pro Jahresabo', unit_price: 29.00 }, + breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 }, + }; + const _year = new Date().getFullYear(); + const _period = `01.01.${_year} – 31.12.${_year}`; + + el.querySelectorAll('.adm-invoice-btn').forEach(btn => { + btn.addEventListener('click', () => { + const { name, email, tier, address } = btn.dataset; + const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 }; + _openNeueRechnungModal(() => { + _tab = 'rechnungen'; + _renderTab(); + }, { + recipient_name: name, + recipient_email: email, + recipient_address: address || '', + service_period: _period, + items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }], + }); + }); + }); } // ------------------------------------------------------------------ @@ -3789,8 +3824,9 @@ window.Page_admin = (() => { }); } - function _openNeueRechnungModal(reload) { + function _openNeueRechnungModal(reload, prefill = null) { const id = `inv-new-${Date.now()}`; + const p = prefill || {}; UI.modal.open({ title: `${UI.icon('receipt')} Neue Rechnung erstellen`, @@ -3801,25 +3837,32 @@ window.Page_admin = (() => {
- +
- +
- + + style="resize:vertical;font-family:inherit">${_esc(p.recipient_address || '')}
+ placeholder="01.01.2026 – 31.12.2026" + value="${_esc(p.service_period || '')}">
@@ -3915,8 +3958,12 @@ window.Page_admin = (() => { `; } - // Erste Position hinzufügen - _addItem('Ban Yaro Pro Jahresabo', 1, 29.00); + // Erste Position — aus Prefill oder Standard + if (p.items && p.items.length) { + p.items.forEach(it => _addItem(it.description, it.quantity ?? 1, it.unit_price ?? 0)); + } else { + _addItem('Ban Yaro Pro Jahresabo', 1, 29.00); + } // Weitere Position document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem()); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 2d23a96..3cc8ac9 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -307,6 +307,12 @@ window.Page_settings = (() => { Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden. Wir melden uns mit den Zahlungsdetails per E-Mail. + ${!_appState.user?.billing_address ? ` +
+ 💡 Tipp: Trag deine Rechnungsadresse im Profil ein — dann können wir die Rechnung vollständig ausstellen. +
` : ''} ${breederForm} `, footer: ` @@ -1135,6 +1141,13 @@ window.Page_settings = (() => { value="${_esc(u.social_link || '')}" style="${inputStyle}"> +
+ +
Wird auf Rechnungen gedruckt. Straße in Zeile 1, PLZ + Ort in Zeile 2.
+ +
@@ -1161,6 +1174,7 @@ window.Page_settings = (() => { erfahrung: fd.erfahrung || '', social_link: fd.social_link || '', profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public', + billing_address: fd.billing_address || '', }); Object.assign(_appState.user, updated); UI.modal.close?.(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 2293f58..6e8b3b5 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v966'; +const CACHE_VERSION = 'by-v967'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 8e36eb0611700b58e2fcb99bcb13078ee86f9173 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 11:13:28 +0200 Subject: [PATCH 013/113] =?UTF-8?q?Fix:=20PDF=20=E2=80=94=20breiter=20Head?= =?UTF-8?q?er-Balken=20mit=20Logo,=20eine=20Seite,=20Hinweis-Prefix,=20Foo?= =?UTF-8?q?ter=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/invoices.py | 147 +++++++++++++++++++------------------ 1 file changed, 75 insertions(+), 72 deletions(-) diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index a59e231..66a6d63 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -66,7 +66,7 @@ def _generate_pdf(invoice, items) -> bytes: KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true" STEUERNUMMER = os.getenv("STEUERNUMMER", "") - INHABER = os.getenv("RECHNUNG_INHABER", "René Degelmann") + INHABER = os.getenv("RECHNUNG_INHABER", "Rene Degelmann") FIRMA = os.getenv("RECHNUNG_GESCHAEFTSNAME", "Ban Yaro") STRASSE = os.getenv("RECHNUNG_STRASSE", "") PLZ_ORT = os.getenv("RECHNUNG_PLZ_ORT", "") @@ -76,11 +76,11 @@ def _generate_pdf(invoice, items) -> bytes: BIC = os.getenv("RECHNUNG_BIC", "") BANKNAME = os.getenv("RECHNUNG_BANK", "") - OR = (230, 126, 34) # Ban Yaro Orange - DK = (30, 30, 30) # Dunkelgrau Text - GY = (130, 130, 130) # Grau - LG = (245, 245, 245) # Hellgrau Hintergrund - WH = (255, 255, 255) # Weiss + OR = (230, 126, 34) + DK = (30, 30, 30) + GY = (130, 130, 130) + LG = (245, 245, 245) + WH = (255, 255, 255) def eur(v: float) -> str: s = f"{v:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") @@ -98,41 +98,50 @@ def _generate_pdf(invoice, items) -> bytes: except Exception: due_date = "" + icon_path = os.path.join(os.path.dirname(__file__), "..", "static", "icons", "icon-192.png") + icon_path = os.path.abspath(icon_path) + pdf = FPDF() pdf.add_page() - pdf.set_margins(20, 15, 20) - pdf.set_auto_page_break(auto=True, margin=28) - W = 170 # nutzbareite Breite (210 - 20 - 20) + pdf.set_margins(20, 0, 20) + pdf.set_auto_page_break(auto=True, margin=22) + W = 170 - # ── Orangener Balken oben ───────────────────────────────────── + # ── Header-Balken (volle Breite, 16mm) ─────────────────────── pdf.set_fill_color(*OR) - pdf.rect(20, 15, W, 1.5, "F") + pdf.rect(0, 0, 210, 16, "F") - # ── Firmenname rechts oben ──────────────────────────────────── - pdf.set_xy(20, 20) - pdf.set_font("Helvetica", "B", 22) - pdf.set_text_color(*OR) - pdf.cell(W, 10, FIRMA, align="R", new_x="LMARGIN", new_y="NEXT") + # App-Icon links im Balken + if os.path.exists(icon_path): + pdf.image(icon_path, x=18, y=1, w=14, h=14) - # ── Absenderadresse rechts, klein ───────────────────────────── - pdf.set_font("Helvetica", "", 8.5) + # "Ban Yaro" in Weiss rechts im Balken + pdf.set_xy(20, 1) + pdf.set_font("Helvetica", "B", 20) + pdf.set_text_color(*WH) + pdf.cell(W, 14, FIRMA, align="R") + + # ── Absenderadresse rechts (unterhalb Balken) ───────────────── + pdf.set_font("Helvetica", "", 8) pdf.set_text_color(*GY) + y_addr = 19 for line in filter(None, [INHABER, STRASSE, PLZ_ORT, EMAIL, WEBSITE]): - pdf.set_x(20) - pdf.cell(W, 4.5, line, align="R", new_x="LMARGIN", new_y="NEXT") + pdf.set_xy(20, y_addr) + pdf.cell(W, 4, line, align="R") + y_addr += 4.2 - # ── Absenderzeile über Empfängerfeld (DIN 5008) ─────────────── + # ── Absenderzeile + Trennstrich (DIN 5008) ──────────────────── sender_ref = " · ".join(filter(None, [FIRMA, INHABER, STRASSE, PLZ_ORT])) - pdf.set_xy(20, 56) + pdf.set_xy(20, 46) pdf.set_font("Helvetica", "", 6.5) pdf.set_text_color(*GY) - pdf.cell(85, 4, sender_ref) + pdf.cell(85, 3.5, sender_ref) pdf.set_draw_color(*GY) pdf.set_line_width(0.15) - pdf.line(20, 60.5, 105, 60.5) + pdf.line(20, 50, 105, 50) - # ── Empfänger (links, DIN-5008-Fensterfeld) ─────────────────── - pdf.set_xy(20, 63) + # ── Empfänger links ─────────────────────────────────────────── + pdf.set_xy(20, 52) pdf.set_font("Helvetica", "B", 10) pdf.set_text_color(*DK) pdf.cell(85, 5.5, invoice["recipient_name"], new_x="LMARGIN", new_y="NEXT") @@ -147,8 +156,7 @@ def _generate_pdf(invoice, items) -> bytes: pdf.set_text_color(*GY) pdf.cell(85, 5, invoice["recipient_email"]) - # ── Info-Block rechts (auf Empfänger-Höhe) ──────────────────── - # x=110, label 35mm + wert 25mm = 60mm → endet bei 170mm ✓ + # ── Info-Block rechts ───────────────────────────────────────── info_rows = [ ("Rechnungsnummer", invoice["invoice_number"]), ("Datum", fdate(invoice.get("created_at", ""))), @@ -157,7 +165,7 @@ def _generate_pdf(invoice, items) -> bytes: if invoice.get("service_period"): info_rows.append(("Leistungszeitraum", invoice["service_period"])) - y_info = 63 + y_info = 52 for lbl, val in info_rows: pdf.set_xy(110, y_info) pdf.set_font("Helvetica", "", 8.5) @@ -169,17 +177,16 @@ def _generate_pdf(invoice, items) -> bytes: y_info += 6 # ── Betreff ─────────────────────────────────────────────────── - pdf.set_xy(20, 100) - pdf.set_font("Helvetica", "B", 14) + pdf.set_xy(20, 90) + pdf.set_font("Helvetica", "B", 13) pdf.set_text_color(*DK) - pdf.cell(W, 8, f"Rechnung {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT") + pdf.cell(W, 7, f"Rechnung {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT") pdf.set_draw_color(*OR) pdf.set_line_width(0.6) pdf.line(20, pdf.get_y(), 190, pdf.get_y()) - pdf.ln(5) + pdf.ln(4) # ── Positionen-Tabelle ──────────────────────────────────────── - # Spalten: 90 + 18 + 32 + 30 = 170mm ✓ CW = (90, 18, 32, 30) pdf.set_fill_color(*OR) @@ -203,24 +210,21 @@ def _generate_pdf(invoice, items) -> bytes: pdf.cell(CW[3], 7, eur(item["total"]), border="B", fill=True, align="R", new_x="LMARGIN", new_y="NEXT") - pdf.ln(5) + pdf.ln(4) - # ── Summenblock (rechtsbündig, x=110) ───────────────────────── - # x=110: label 50mm + wert 30mm = 80mm → endet bei 190mm ✓ + # ── Summenblock ─────────────────────────────────────────────── def srow(lbl, val, bold=False, txt_color=None, bg=None): pdf.set_x(110) pdf.set_fill_color(*(bg or WH)) pdf.set_text_color(*(txt_color or DK)) pdf.set_font("Helvetica", "B" if bold else "", 10 if bold else 9) - pdf.cell(50, 6.5, lbl, align="R", fill=bool(bg)) - pdf.cell(30, 6.5, val, align="R", fill=bool(bg), new_x="LMARGIN", new_y="NEXT") + pdf.cell(50, 6, lbl, align="R", fill=bool(bg)) + pdf.cell(30, 6, val, align="R", fill=bool(bg), new_x="LMARGIN", new_y="NEXT") srow("Nettobetrag:", eur(invoice["amount_net"])) - if invoice.get("discount_pct") and invoice["discount_pct"] > 0: srow(f"Rabatt ({invoice['discount_pct']:.0f}%):", f"- {eur(invoice['discount_amount'])}", txt_color=OR) srow("Nach Rabatt:", eur(invoice["amount_after_discount"])) - if not KLEINUNTERNEHMER and invoice.get("tax_rate", 0) > 0: srow(f"MwSt. {invoice['tax_rate']:.0f}%:", eur(invoice["tax_amount"])) @@ -229,59 +233,58 @@ def _generate_pdf(invoice, items) -> bytes: pdf.line(110, pdf.get_y(), 190, pdf.get_y()) pdf.ln(1) srow("Gesamtbetrag:", eur(invoice["amount_gross"]), bold=True, bg=LG) - pdf.ln(4) + pdf.ln(3) - # ── Steuerhinweis / Kleinunternehmer ────────────────────────── + # ── §19-Hinweis ─────────────────────────────────────────────── if KLEINUNTERNEHMER: pdf.set_x(20) pdf.set_font("Helvetica", "I", 8.5) pdf.set_text_color(*GY) - pdf.multi_cell(W, 5, "Gem. § 19 UStG wird keine Umsatzsteuer berechnet.") + pdf.multi_cell(W, 5, "Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet.") # ── Zahlungsinfo-Box ────────────────────────────────────────── - if IBAN or due_date: - pdf.ln(6) - y_box = pdf.get_y() + pdf.ln(5) + y_box = pdf.get_y() + pdf.set_x(24) + pdf.set_font("Helvetica", "B", 9) + pdf.set_text_color(*OR) + pdf.cell(W - 4, 6, "Zahlungsinformationen", new_x="LMARGIN", new_y="NEXT") + + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(*DK) + pay_rows = [] + if due_date: pay_rows.append(("Zahlbar bis:", due_date)) + if IBAN: pay_rows.append(("IBAN:", IBAN)) + if BIC: pay_rows.append(("BIC:", BIC)) + if BANKNAME: pay_rows.append(("Bank:", BANKNAME)) + pay_rows.append( ("Verwendungszweck:", invoice["invoice_number"])) + + for lbl, val in pay_rows: pdf.set_x(24) - pdf.set_font("Helvetica", "B", 9) - pdf.set_text_color(*OR) - pdf.cell(W - 4, 6, "Zahlungsinformationen", new_x="LMARGIN", new_y="NEXT") - pdf.set_font("Helvetica", "", 9) - pdf.set_text_color(*DK) - pay_rows = [] - if due_date: pay_rows.append(("Zahlbar bis:", due_date)) - if IBAN: pay_rows.append(("IBAN:", IBAN)) - if BIC: pay_rows.append(("BIC:", BIC)) - if BANKNAME: pay_rows.append(("Bank:", BANKNAME)) - pay_rows.append( ("Verwendungszweck:", invoice["invoice_number"])) + pdf.cell(45, 5.5, lbl) + pdf.set_font("Helvetica", "" if lbl == "Verwendungszweck:" else "B", 9) + pdf.cell(0, 5.5, val, new_x="LMARGIN", new_y="NEXT") - for lbl, val in pay_rows: - pdf.set_x(24) - pdf.set_font("Helvetica", "", 9) - pdf.cell(45, 5.5, lbl) - pdf.set_font("Helvetica", "" if lbl == "Verwendungszweck:" else "B", 9) - pdf.cell(0, 5.5, val, new_x="LMARGIN", new_y="NEXT") - - # Linker oranger Akzentbalken - pdf.set_fill_color(*OR) - pdf.rect(20, y_box, 2, pdf.get_y() - y_box + 1, "F") + pdf.set_fill_color(*OR) + pdf.rect(20, y_box, 2, pdf.get_y() - y_box + 1, "F") # ── Notizen ─────────────────────────────────────────────────── if invoice.get("notes"): - pdf.ln(5) + pdf.ln(4) pdf.set_x(20) pdf.set_font("Helvetica", "I", 9) pdf.set_text_color(*GY) pdf.multi_cell(W, 5, str(invoice["notes"])) - # ── Footer mit Pflichtangaben ───────────────────────────────── - pdf.set_y(-18) + # ── Footer (fixiert auf Seite 1, kein auto-break) ───────────── + pdf.set_auto_page_break(False) + pdf.set_y(277) pdf.set_draw_color(*OR) pdf.set_line_width(0.4) pdf.line(20, pdf.get_y(), 190, pdf.get_y()) - pdf.ln(2) + pdf.ln(1.5) footer_parts = [FIRMA, INHABER] if STEUERNUMMER: From 41a4808ad545da360d8ce697df1b435adbf27ed0 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 11:18:48 +0200 Subject: [PATCH 014/113] =?UTF-8?q?Fix:=20Storno=20sendet=20PDF+Mail+Scani?= =?UTF-8?q?nput;=20/scaninput=20Volume=20in=20staging=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/invoices.py | 66 ++++++++++++++++++++++++++++++++++++-- docker-compose.staging.yml | 1 + 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 66a6d63..dd71609 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -599,7 +599,7 @@ def pay_invoice(invoice_id: int, data: PayBody, admin=Depends(require_admin)): @router.post("/{invoice_id}/cancel") -def cancel_invoice(invoice_id: int, data: CancelBody, admin=Depends(require_admin)): +async def cancel_invoice(invoice_id: int, data: CancelBody, admin=Depends(require_admin)): with db() as conn: row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() if not row: @@ -613,4 +613,66 @@ def cancel_invoice(invoice_id: int, data: CancelBody, admin=Depends(require_admi (now, data.reason, cancellation_number, invoice_id) ) row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() - return _row_to_dict(row) + items = _fetch_items(conn, invoice_id) + + invoice = _row_to_dict(row) + + # Storno-PDF: invoice-Dict als Stornobeleg aufbereiten + storno_invoice = dict(invoice) + storno_invoice["invoice_number"] = cancellation_number + storno_invoice["notes"] = ( + f"Stornierung zu Rechnung {invoice['invoice_number']}\n" + f"Grund: {data.reason}" + ) + storno_invoice["amount_net"] = -invoice["amount_net"] + storno_invoice["discount_amount"] = -invoice.get("discount_amount", 0) + storno_invoice["amount_after_discount"] = -invoice["amount_after_discount"] + storno_invoice["tax_amount"] = -invoice.get("tax_amount", 0) + storno_invoice["amount_gross"] = -invoice["amount_gross"] + for item in items: + item["unit_price"] = -item["unit_price"] + item["total"] = -item["total"] + + try: + pdf_bytes = _generate_pdf(storno_invoice, items) + except Exception as e: + logger.error(f"Storno-PDF fehlgeschlagen: {e}") + return _row_to_dict(row) + + filename = f"{cancellation_number}_banyaro.pdf" + + try: + await _save_to_paperless(pdf_bytes, cancellation_number, filename) + except Exception as e: + logger.warning(f"Storno Paperless fehlgeschlagen: {e}") + + # Mail an Kunden + try: + body_html = mailer.email_html(f""" +

Hallo {invoice['recipient_name']},

+

+ Ihre Rechnung {invoice['invoice_number']} wurde storniert + (Stornonummer: {cancellation_number}). +

+

Grund: {data.reason}

+

+ Das Stornodokument liegt diesem Schreiben bei. +

+ """) + plain = ( + f"Hallo {invoice['recipient_name']},\n\n" + f"Ihre Rechnung {invoice['invoice_number']} wurde storniert " + f"(Stornonummer: {cancellation_number}).\n" + f"Grund: {data.reason}\n" + ) + await mailer.send_email( + to=invoice["recipient_email"], + subject=f"Stornierung Rechnung {invoice['invoice_number']} — Ban Yaro", + html=body_html, + plain=plain, + attachments=[{"filename": filename, "content": pdf_bytes, "content_type": "application/pdf"}], + ) + except Exception as e: + logger.error(f"Storno-Mail fehlgeschlagen: {e}") + + return invoice diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index d9ef97e..38b4182 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -8,6 +8,7 @@ services: volumes: - ./data:/data - /volume1/docker/banyaro/data/media:/prod-media:ro + - /volume1/scaninput:/scaninput env_file: - .env environment: From a2d089bce4be9153b90c91e6f0e78693cc220829 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 11:29:16 +0200 Subject: [PATCH 015/113] Fix: SMTP_SUPPORT_PASS Fallback in mailer, scaninput Logging --- backend/mailer.py | 2 +- backend/routes/invoices.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/mailer.py b/backend/mailer.py index 03d9228..f81fb33 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -26,7 +26,7 @@ BREVO_API_URL = "https://api.brevo.com/v3/smtp/email" SMTP_HOST = os.getenv("SMTP_HOST", "") SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SMTP_USER = os.getenv("SMTP_USER", "") -SMTP_PASS = os.getenv("SMTP_PASS", "") +SMTP_PASS = os.getenv("SMTP_PASS", "") or os.getenv("SMTP_SUPPORT_PASS", "") SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro ") APP_URL = os.getenv("APP_URL", "https://banyaro.app") diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index dd71609..e330a12 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -308,6 +308,7 @@ async def _save_to_paperless(pdf_bytes: bytes, invoice_number: str, filename: st path = os.path.join(scaninput, filename) with open(path, "wb") as f: f.write(pdf_bytes) + logger.info(f"PDF gespeichert: {path} ({len(pdf_bytes)} Bytes)") paperless_url = os.getenv("PAPERLESS_URL", "") paperless_token = os.getenv("PAPERLESS_TOKEN", "") From b14a251bdcd7dd87c1d29c8f4f593eae4f3e455e Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 11:33:48 +0200 Subject: [PATCH 016/113] Feat: Entwurf bearbeiten (PATCH), erneut senden; SW by-v968 --- backend/main.py | 2 +- backend/routes/invoices.py | 54 ++++++++++++++++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 47 ++++++++++++++++++++++----- backend/static/sw.js | 2 +- 5 files changed, 96 insertions(+), 11 deletions(-) diff --git a/backend/main.py b/backend/main.py index 853e8a4..8118b87 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "967" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "968" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index e330a12..de23157 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -444,6 +444,58 @@ def get_invoice(invoice_id: int, admin=Depends(require_admin)): return result +@router.patch("/{invoice_id}") +def update_invoice(invoice_id: int, data: InvoiceCreate, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + if not row: + raise HTTPException(404, "Rechnung nicht gefunden.") + if row["status"] != "draft": + raise HTTPException(400, "Nur Entwürfe können bearbeitet werden.") + if not data.items: + raise HTTPException(400, "Mindestens eine Position erforderlich.") + + KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true" + TAX_RATE = 0.0 if KLEINUNTERNEHMER else float(os.getenv("RECHNUNG_MWST", "19")) + + amount_net = round(sum(i.quantity * i.unit_price for i in data.items), 2) + discount_pct = data.discount_pct or 0.0 + discount_amount = round(amount_net * discount_pct / 100, 2) + amount_after_discount = round(amount_net - discount_amount, 2) + tax_amount = round(amount_after_discount * TAX_RATE / 100, 2) + amount_gross = round(amount_after_discount + tax_amount, 2) + description = data.items[0].description if len(data.items) == 1 else f"{len(data.items)} Positionen" + + conn.execute(""" + UPDATE invoices SET + recipient_name=?, recipient_email=?, recipient_address=?, + description=?, service_period=?, + amount_net=?, discount_pct=?, discount_amount=?, + amount_after_discount=?, tax_rate=?, tax_amount=?, amount_gross=?, + notes=? + WHERE id=? + """, ( + data.recipient_name, data.recipient_email, data.recipient_address, + description, data.service_period, + amount_net, discount_pct, discount_amount, + amount_after_discount, TAX_RATE, tax_amount, amount_gross, + data.notes, invoice_id, + )) + conn.execute("DELETE FROM invoice_items WHERE invoice_id=?", (invoice_id,)) + for item in data.items: + total = round(item.quantity * item.unit_price, 2) + conn.execute( + "INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)", + (invoice_id, item.description, item.quantity, item.unit_price, total) + ) + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + items = _fetch_items(conn, invoice_id) + + result = _row_to_dict(row) + result["items"] = items + return result + + @router.post("", status_code=201) def create_invoice(data: InvoiceCreate, admin=Depends(require_admin)): if not data.items: @@ -501,6 +553,8 @@ async def send_invoice(invoice_id: int, admin=Depends(require_admin)): raise HTTPException(404, "Rechnung nicht gefunden.") if row["status"] == "cancelled": raise HTTPException(400, "Stornierte Rechnung kann nicht gesendet werden.") + if row["status"] == "paid": + raise HTTPException(400, "Bezahlte Rechnung kann nicht erneut gesendet werden.") items = _fetch_items(conn, invoice_id) invoice = _row_to_dict(row) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index b972a93..ef71b8a 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '967'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '968'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index fed5b2b..7472a79 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3726,10 +3726,19 @@ window.Page_admin = (() => { const rows = invoices.map((inv, i) => { const actions = []; if (inv.status === 'draft') { + actions.push(``); actions.push(``); } + if (inv.status === 'sent') { + actions.push(``); + } if (inv.status === 'sent') { actions.push(` - + `, }); @@ -3988,7 +4014,7 @@ window.Page_admin = (() => { if (submitBtn) submitBtn.disabled = true; try { - await API.post('/admin/invoices', { + const payload = { recipient_name: fd.get('recipient_name'), recipient_email: fd.get('recipient_email') || null, recipient_address: fd.get('recipient_address') || null, @@ -3996,9 +4022,14 @@ window.Page_admin = (() => { discount_pct: parseFloat(fd.get('discount_pct')) || 0, notes: fd.get('notes') || null, items, - }); + }; + if (isEdit) { + await API.patch(`/admin/invoices/${invoiceId}`, payload); + } else { + await API.post('/admin/invoices', payload); + } UI.modal.close(); - UI.toast.success('Rechnung erstellt.'); + UI.toast.success(isEdit ? 'Rechnung gespeichert.' : 'Rechnung erstellt.'); reload(); } catch (err) { UI.toast.error(err.message || 'Fehler beim Erstellen.'); diff --git a/backend/static/sw.js b/backend/static/sw.js index 6e8b3b5..cd091e8 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v967'; +const CACHE_VERSION = 'by-v968'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 24a1aecda4b399789f6ddeea3b2185dff08f5bd3 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 11:45:13 +0200 Subject: [PATCH 017/113] Fix: Leistungszeitraum '12 Monate ab Rechnungsdatum' statt festem Datum (SW by-v969) --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 4 ++-- backend/static/sw.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8118b87..77526ee 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "968" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "969" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index ef71b8a..6082558 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '968'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '969'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 7472a79..0c71c30 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3627,7 +3627,7 @@ window.Page_admin = (() => { breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 }, }; const _year = new Date().getFullYear(); - const _period = `01.01.${_year} – 31.12.${_year}`; + const _period = '12 Monate ab Rechnungsdatum'; el.querySelectorAll('.adm-invoice-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -3887,7 +3887,7 @@ window.Page_admin = (() => {
diff --git a/backend/static/sw.js b/backend/static/sw.js index cd091e8..f814166 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v968'; +const CACHE_VERSION = 'by-v969'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 96030304d408766b7ddb22d312666376d37559aa Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 11:49:48 +0200 Subject: [PATCH 018/113] Fix: Leistungszeitraum als konkreter Datumszeitraum (Rechnungsdatum bis +12 Monate) --- backend/static/js/pages/admin.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 0c71c30..46074b9 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3627,7 +3627,10 @@ window.Page_admin = (() => { breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 }, }; const _year = new Date().getFullYear(); - const _period = '12 Monate ab Rechnungsdatum'; + const _now = new Date(); + const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1); + const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`; + const _period = `${_fmt(_now)} – ${_fmt(_end)}`; el.querySelectorAll('.adm-invoice-btn').forEach(btn => { btn.addEventListener('click', () => { From b1dbde332f74cd89db3b1cbca6492f7e6b59b9ad Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 11:53:29 +0200 Subject: [PATCH 019/113] Feat: Erneuerungsrechnung-Entwurf 30 Tage vor Abo-Ablauf + 7-Tage-Erinnerung an Admin --- backend/scheduler.py | 121 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/backend/scheduler.py b/backend/scheduler.py index 510b348..a1edb8a 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -207,6 +207,119 @@ def stop(): # ------------------------------------------------------------------ # JOB: Abo-Ablauf prüfen (täglich 03:00) # ------------------------------------------------------------------ +_TIER_PRICE = {"pro": 29.00, "breeder": 49.00} + + +async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: str): + """Legt einen Rechnungs-Entwurf für die Abo-Verlängerung an, sofern noch keiner existiert.""" + import os + from mailer import send_email, email_html + from routes.invoices import _next_invoice_number + + tier = user["subscription_tier"] + price = _TIER_PRICE.get(tier, 29.00) + # Verlängerungszeitraum: Folgetag nach Ablauf bis +1 Jahr + start = expires + timedelta(days=1) + end = start.replace(year=start.year + 1) - timedelta(days=1) + period = f"{start.strftime('%d.%m.%Y')} – {end.strftime('%d.%m.%Y')}" + + with db() as conn: + # Nur anlegen wenn noch kein Entwurf/offener Eintrag für diesen User + Zeitraum + existing = conn.execute( + """SELECT id FROM invoices + WHERE user_id=? AND status IN ('draft','sent') + AND service_period=?""", + (user["id"], period) + ).fetchone() + if existing: + logger.info(f"Erneuerungsrechnung bereits vorhanden für user {user['id']}") + return + + # Billing-Adresse des Users laden + row = conn.execute( + "SELECT billing_address FROM users WHERE id=?", (user["id"],) + ).fetchone() + billing_address = row["billing_address"] if row else None + + invoice_number = _next_invoice_number(conn) + description = f"{tier_label} Jahresabo (Verlängerung)" + conn.execute(""" + INSERT INTO invoices + (invoice_number, user_id, recipient_name, recipient_email, recipient_address, + description, service_period, amount_net, discount_pct, discount_amount, + amount_after_discount, tax_rate, tax_amount, amount_gross, notes) + VALUES (?,?,?,?,?,?,?,?,0,0,?,0,0,?,?) + """, ( + invoice_number, user["id"], user["name"], user["email"], billing_address, + description, period, + price, price, price, + f"Automatisch erstellt — Abo läuft am {expires.strftime('%d.%m.%Y')} ab.", + )) + conn.execute( + "INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)", + (conn.execute("SELECT last_insert_rowid()").fetchone()[0], description, price, price) + ) + + logger.info(f"Erneuerungsrechnung {invoice_number} als Entwurf angelegt für {user['email']}") + + # Admin-Benachrichtigung + admin_email = os.getenv("ADMIN_EMAIL", "") + if admin_email: + app_url = os.getenv("APP_URL", "https://banyaro.app") + body = f""" +

Für {user['name']} ({user['email']}) wurde automatisch ein + Rechnungsentwurf für die Abo-Verlängerung erstellt.

+ + + + + + +
Rechnung:{invoice_number}
Tarif:{tier_label}
Betrag:{price:.2f} EUR
Zeitraum:{period}
Abo läuft ab:{expires.strftime('%d.%m.%Y')} (in 30 Tagen)
+

Bitte prüfen, ggf. anpassen und rechtzeitig versenden.

""" + html = email_html(body, cta_url=f"{app_url}/#admin", cta_label="Zur Rechnung im Admin") + await send_email( + admin_email, + f"Erneuerungsrechnung {invoice_number} bereit — {user['name']}", + html, + f"Entwurf {invoice_number} für {user['name']} ({tier_label}, {price:.2f} EUR, {period}) bereit." + ) + + +async def _remind_renewal_invoice(user: dict, expires: date, tier_label: str): + """7-Tage-Erinnerung an René: Entwurf noch nicht versendet.""" + import os + from mailer import send_email, email_html + + with db() as conn: + draft = conn.execute( + "SELECT invoice_number FROM invoices WHERE user_id=? AND status='draft' LIMIT 1", + (user["id"],) + ).fetchone() + + if not draft: + return # kein offener Entwurf, nichts zu erinnern + + admin_email = os.getenv("ADMIN_EMAIL", "") + if not admin_email: + return + + app_url = os.getenv("APP_URL", "https://banyaro.app") + body = f""" +

Achtung: Das Abo von {user['name']} ({user['email']}) + läuft in 7 Tagen (am {expires.strftime('%d.%m.%Y')}) ab.

+

Rechnungsentwurf {draft['invoice_number']} wurde noch nicht versendet. + Bitte jetzt versenden damit der Kunde rechtzeitig bezahlen kann.

""" + html = email_html(body, cta_url=f"{app_url}/#admin", cta_label="Rechnung jetzt senden") + await send_email( + admin_email, + f"⚠ Noch 7 Tage — Erneuerungsrechnung {draft['invoice_number']} nicht versendet", + html, + f"Entwurf {draft['invoice_number']} für {user['name']} noch nicht versendet. Abo läuft in 7 Tagen ab." + ) + logger.info(f"7-Tage-Erinnerung an Admin für {user['email']}: {draft['invoice_number']}") + + async def _job_subscription_check(): """Abgelaufene Abos auf Standard setzen; Warnmails 30 und 7 Tage vorher.""" from database import db as _db @@ -253,7 +366,7 @@ async def _job_subscription_check(): await send_email(u["email"], f"Dein {tier_label}-Abo ist abgelaufen", html, f"Hallo {u['name']},\ndein {tier_label}-Abo ist abgelaufen. Daten bleiben erhalten.") - # 30 Tage Warnung + # 30 Tage Warnung + Erneuerungsrechnung als Entwurf anlegen elif days_left == 30: body = f"""

Hallo {_html.escape(u['name'])},

@@ -265,7 +378,10 @@ async def _job_subscription_check(): await send_email(u["email"], f"Dein {tier_label}-Abo läuft in 30 Tagen ab", html, f"Hallo {u['name']},\ndein {tier_label}-Abo läuft in 30 Tagen ab ({expires}).") - # 7 Tage Warnung + # Erneuerungsrechnung als Entwurf anlegen (nur wenn noch keine existiert) + await _create_renewal_invoice_draft(u, expires, tier_label) + + # 7 Tage — Warnung an User + Erinnerung an René falls Entwurf noch nicht versendet elif days_left == 7: body = f"""

Hallo {_html.escape(u['name'])},

@@ -275,6 +391,7 @@ async def _job_subscription_check(): html = email_html(body, cta_url="https://banyaro.app", cta_label="Abo verlängern") await send_email(u["email"], f"Nur noch 7 Tage — {tier_label}-Abo läuft ab", html, f"Hallo {u['name']},\nnur noch 7 Tage für dein {tier_label}-Abo.") + await _remind_renewal_invoice(u, expires, tier_label) except Exception as e: logger.warning(f"subscription_check Fehler für {u['email']}: {e}") From a9f7923716f95cdb33a4e065ea3afff14f2c1ac7 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 12:00:27 +0200 Subject: [PATCH 020/113] =?UTF-8?q?Feat:=20K=C3=BCndigung=20blockt=20Erneu?= =?UTF-8?q?erungsentwurf;=20Upgrade=20storniert=20alte=20Rechnungen=20+=20?= =?UTF-8?q?legt=20neuen=20Entwurf=20an?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/admin.py | 63 ++++++++++++++++++++++++++++++++++++++++- backend/scheduler.py | 8 +++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 8eb07ba..4972bb6 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1150,7 +1150,7 @@ async def list_upgrade_requests(user=Depends(require_admin)): async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)): with db() as conn: req = conn.execute( - "SELECT r.*, u.name, u.email FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?", + "SELECT r.*, u.name, u.email, u.subscription_tier AS old_tier FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?", (req_id,) ).fetchone() if not req: @@ -1259,9 +1259,70 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)): import logging logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}") + # Offene Rechnungen (sent/draft) des alten Tiers stornieren + neuen Entwurf anlegen + try: + await _handle_upgrade_invoices(req, tier_label) + except Exception as e: + logger.warning(f"Upgrade-Rechnungslogik fehlgeschlagen für {req['name']}: {e}") + return {"ok": True, "tier": req["tier"], "user": req["name"]} +async def _handle_upgrade_invoices(req: dict, new_tier_label: str): + """Storniert offene Rechnungen des alten Tiers und legt neuen Entwurf an.""" + from routes.invoices import _next_invoice_number + from datetime import timedelta + + with db() as conn: + # Offene Rechnungen (draft + sent) dieses Users finden + open_invoices = conn.execute( + "SELECT * FROM invoices WHERE user_id=? AND status IN ('draft','sent')", + (req["user_id"],) + ).fetchall() + + for inv in open_invoices: + cancel_num = _next_invoice_number(conn, "ST") + conn.execute( + """UPDATE invoices SET status='cancelled', cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now'), + cancellation_reason=?, cancellation_number=? WHERE id=?""", + (f"Tarif-Upgrade auf {new_tier_label}", cancel_num, inv["id"]) + ) + logger.info(f"Rechnung {inv['invoice_number']} storniert ({cancel_num}) — Upgrade auf {new_tier_label}") + + # Neuen Entwurf für den neuen Tier anlegen + tier = req["tier"] + price = {"pro": 29.00, "breeder": 49.00}.get(tier, 29.00) + today = datetime.now(_TZ).date() + end_date = today.replace(year=today.year + 1) - timedelta(days=1) + period = f"{today.strftime('%d.%m.%Y')} – {end_date.strftime('%d.%m.%Y')}" + description = f"{new_tier_label} Jahresabo" + + billing = conn.execute( + "SELECT billing_address FROM users WHERE id=?", (req["user_id"],) + ).fetchone() + billing_address = billing["billing_address"] if billing else None + + inv_number = _next_invoice_number(conn) + conn.execute(""" + INSERT INTO invoices + (invoice_number, user_id, recipient_name, recipient_email, recipient_address, + description, service_period, amount_net, discount_pct, discount_amount, + amount_after_discount, tax_rate, tax_amount, amount_gross, notes) + VALUES (?,?,?,?,?,?,?,?,0,0,?,0,0,?,?) + """, ( + inv_number, req["user_id"], req["name"], req["email"], billing_address, + description, period, price, price, price, + f"Automatisch bei Upgrade von {req.get('old_tier','Standard')} auf {new_tier_label}.", + )) + invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute( + "INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)", + (invoice_id, description, price, price) + ) + + logger.info(f"Neuer Rechnungsentwurf {inv_number} für {req['email']} nach Upgrade auf {new_tier_label}") + + # ------------------------------------------------------------------ # Helpers: Quartalsdaten # ------------------------------------------------------------------ diff --git a/backend/scheduler.py b/backend/scheduler.py index a1edb8a..8a7b24d 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -216,6 +216,11 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s from mailer import send_email, email_html from routes.invoices import _next_invoice_number + # Gekündigte Abos bekommen keine Erneuerungsrechnung + if user.get("subscription_cancelled_at"): + logger.info(f"Kein Erneuerungsentwurf für {user['email']} — Abo ist gekündigt.") + return + tier = user["subscription_tier"] price = _TIER_PRICE.get(tier, 29.00) # Verlängerungszeitraum: Folgetag nach Ablauf bis +1 Jahr @@ -330,7 +335,8 @@ async def _job_subscription_check(): with _db() as conn: users = conn.execute( - """SELECT id, name, email, subscription_tier, subscription_expires_at + """SELECT id, name, email, subscription_tier, subscription_expires_at, + subscription_cancelled_at FROM users WHERE subscription_tier IN ('pro','breeder') AND subscription_expires_at IS NOT NULL""" From 699926cd76407be941c796960c17378eef9a79d6 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 12:06:05 +0200 Subject: [PATCH 021/113] Fix: Rechnung-Hinweistext auf AGB-konforme Jahresbeitrags-Notiz umgestellt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alle drei Rechnungs-Einstiegspunkte (Admin-Upgrade-Button, automatische Verlängerung via Scheduler, manuelles Upgrade via admin.py) erhalten jetzt den einheitlichen Hinweis zum Jahresbeitrag gem. AGB ohne Rückerstattung. --- backend/routes/admin.py | 2 +- backend/scheduler.py | 2 +- backend/static/js/pages/admin.js | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 4972bb6..c0ef2b7 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1312,7 +1312,7 @@ async def _handle_upgrade_invoices(req: dict, new_tier_label: str): """, ( inv_number, req["user_id"], req["name"], req["email"], billing_address, description, period, price, price, price, - f"Automatisch bei Upgrade von {req.get('old_tier','Standard')} auf {new_tier_label}.", + f"Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen. (Upgrade von {req.get('old_tier','Standard')} auf {new_tier_label})", )) invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( diff --git a/backend/scheduler.py b/backend/scheduler.py index 8a7b24d..edb9963 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -258,7 +258,7 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s invoice_number, user["id"], user["name"], user["email"], billing_address, description, period, price, price, price, - f"Automatisch erstellt — Abo läuft am {expires.strftime('%d.%m.%Y')} ab.", + f"Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen. (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})", )) conn.execute( "INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)", diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 46074b9..3a898ae 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3645,6 +3645,7 @@ window.Page_admin = (() => { recipient_address: address || '', service_period: _period, items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }], + notes: 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.', }); }); }); From 9a7f1008552fc654ae8468c24c38ba7d0043a9ab Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 12:06:14 +0200 Subject: [PATCH 022/113] Legal: Widerrufs-Checkbox im Upgrade-Modal + AGB-Abschnitt in Datenschutz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade-Modal: Checkbox §356 Abs.4 BGB muss aktiv bestätigt werden, "Anfrage senden" bleibt bis dahin deaktiviert - Akzeptanz-Zeitstempel wird mit der upgradeRequest-Message mitgeschickt - datenschutz.js: neuer Abschnitt "Abonnement & Kündigung" mit Laufzeit, Verlängerung, Zahlung, Kündigung, Erstattung und Widerrufsrecht --- backend/static/js/pages/datenschutz.js | 30 ++++++++++++++++++++++++++ backend/static/js/pages/settings.js | 22 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js index 425536f..368010c 100644 --- a/backend/static/js/pages/datenschutz.js +++ b/backend/static/js/pages/datenschutz.js @@ -238,6 +238,36 @@ window.Page_datenschutz = (() => { style="${S.a}">www.lda.bayern.de

`)} + ${sec('Abonnement & Kündigung', ` +

+ Ban Yaro Pro und das Züchter-Paket sind Jahresabonnements mit einer Laufzeit von + 12 Monaten ab Freischaltung. +

+

+ Laufzeit & Verlängerung: Das Abonnement läuft 12 Monate ab dem + Tag der Freischaltung. Nach Ablauf verlängert es sich auf unbestimmte Zeit mit einer + Kündigungsfrist von einem Monat zum Monatsende, sofern nicht vorher gekündigt wird + (§ 309 Nr. 9 BGB). +

+

+ Zahlung: Der Jahresbeitrag (29 EUR für Pro, 49 EUR für + Züchter) wird einmalig für die gesamte Laufzeit im Voraus fällig. +

+

+ Kündigung: Die Kündigung kann jederzeit in den Einstellungen der App + erfolgen. Der Zugang bleibt bis zum Ende der bezahlten Laufzeit erhalten. +

+

+ Erstattung: Bei vorzeitiger Kündigung durch den Nutzer erfolgt keine + anteilige Rückerstattung des Jahresbeitrags. Der Zugang bleibt bis zum Ende der + bezahlten Laufzeit vollständig bestehen. +

+

+ Widerrufsrecht: Da die Nutzung sofort nach Freischaltung beginnt und + der Nutzer dem ausdrücklich zustimmt, erlischt das 14-tägige Widerrufsrecht gemäß + § 356 Abs. 4 BGB mit Beginn der Nutzung. +

`)} + ${sec('Speicherdauer', `

Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst — diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 3cc8ac9..2cb9b49 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -313,6 +313,18 @@ window.Page_settings = (() => { font-size:var(--text-xs);color:#c05000;line-height:1.6;margin-top:var(--space-2)"> 💡 Tipp: Trag deine Rechnungsadresse im Profil ein — dann können wir die Rechnung vollständig ausstellen.

` : ''} +
+ +
${breederForm} `, footer: ` @@ -330,6 +342,13 @@ window.Page_settings = (() => { ` }); + const widerrufBox = document.getElementById('widerruf-checkbox'); + const sendBtn = document.getElementById('upgrade-request-send-btn'); + if (sendBtn) sendBtn.disabled = true; + widerrufBox?.addEventListener('change', () => { + if (sendBtn) sendBtn.disabled = !widerrufBox.checked; + }); + document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => { const btn = document.getElementById('upgrade-request-send-btn'); if (!btn) return; @@ -363,7 +382,8 @@ window.Page_settings = (() => { } try { - const res = await API.auth.upgradeRequest(tier); + const widerrufAt = new Date().toLocaleString('de-DE'); + const res = await API.auth.upgradeRequest(tier, `[Widerrufsrecht akzeptiert am ${widerrufAt}]`); UI.modal.close(); if (res.already) { UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.'); From db4d5cb1b6593cc2add7b3acf8b2b413d53c0696 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 12:07:13 +0200 Subject: [PATCH 023/113] Legal: Widerrufs-Checkbox, AGB-Abschnitt, Rechnungsnotiz (SW by-v970) --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/main.py b/backend/main.py index 77526ee..b460a2b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "969" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "970" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 6082558..467c710 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '969'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '970'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/sw.js b/backend/static/sw.js index f814166..baa576f 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v969'; +const CACHE_VERSION = 'by-v970'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 2163169b730428d5fb9a759bd6ec478366c2c78a Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 12:21:33 +0200 Subject: [PATCH 024/113] =?UTF-8?q?Feat:=20Rabattsystem=20in=20Rechnungser?= =?UTF-8?q?stellung=20integriert=20(Gr=C3=BCnder/Referral)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _get_discount_info() Hilfsfunktion in admin.py (Gründer 100%, Referral-Stufen 20/30/50%, von Gründer eingeladen 50%) - list_upgrade_requests liefert discount_pct + discount_reason pro User - GET /admin/users/{user_id}/discount Endpoint - _handle_upgrade_invoices nutzt Rabatt für amount_net/discount_pct/after_disc + passende Notiz - scheduler.py _create_renewal_invoice_draft: inline Rabattberechnung + korrekte Beträge - admin.js: Discount-Badge in Upgrade-Card, data-Attribute am Invoice-Button, _discountNote(), discount_pct + notes im Modal vorbelegt --- backend/routes/admin.py | 68 +++++++++++++++++++++++++++++--- backend/scheduler.py | 48 ++++++++++++++++++++-- backend/static/js/pages/admin.js | 27 +++++++++++-- 3 files changed, 132 insertions(+), 11 deletions(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index c0ef2b7..47f363b 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1137,13 +1137,28 @@ async def list_upgrade_requests(user=Depends(require_admin)): with db() as conn: rows = conn.execute(""" SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at, - u.name, u.email, u.billing_address + u.name, u.email, u.billing_address, + u.is_founder, u.is_founder_pending, u.referred_by, + COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count FROM upgrade_requests r JOIN users u ON u.id = r.user_id ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC LIMIT 100 """).fetchall() - return [dict(r) for r in rows] + result = [] + for r in rows: + d = dict(r) + d_info = _get_discount_info(conn, r["user_id"]) + d["discount_pct"] = d_info["discount_pct"] + d["discount_reason"] = d_info["reason"] + result.append(d) + return result + + +@router.get("/users/{user_id}/discount") +def get_user_discount(user_id: int, admin=Depends(require_admin)): + with db() as conn: + return _get_discount_info(conn, user_id) @router.post("/upgrade-requests/{req_id}/fulfill") @@ -1268,6 +1283,35 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)): return {"ok": True, "tier": req["tier"], "user": req["name"]} +def _get_discount_info(conn, user_id: int) -> dict: + """Berechnet Rabatt für einen User basierend auf Gründer-Status und Referrals.""" + row = conn.execute( + """SELECT u.is_founder, u.is_founder_pending, u.referred_by, + COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count + FROM users u WHERE u.id=?""", + (user_id,) + ).fetchone() + if not row: + return {"discount_pct": 0, "reason": None, "referral_count": 0} + + if row["is_founder"] or row["is_founder_pending"]: + return {"discount_pct": 100, "reason": "founder", "referral_count": row["referral_count"]} + + referred_by = row["referred_by"] or 0 + if referred_by > 0: + referrer = conn.execute( + "SELECT is_founder, is_founder_pending FROM users WHERE id=?", (referred_by,) + ).fetchone() + if referrer and (referrer["is_founder"] or referrer["is_founder_pending"]): + return {"discount_pct": 50, "reason": "referred_by_founder", "referral_count": row["referral_count"]} + + count = row["referral_count"] + for threshold, pct in [(50, 50), (20, 30), (10, 20)]: + if count >= threshold: + return {"discount_pct": pct, "reason": "referral", "referral_count": count} + return {"discount_pct": 0, "reason": None, "referral_count": count} + + async def _handle_upgrade_invoices(req: dict, new_tier_label: str): """Storniert offene Rechnungen des alten Tiers und legt neuen Entwurf an.""" from routes.invoices import _next_invoice_number @@ -1302,17 +1346,31 @@ async def _handle_upgrade_invoices(req: dict, new_tier_label: str): ).fetchone() billing_address = billing["billing_address"] if billing else None + disc_info = _get_discount_info(conn, req["user_id"]) + discount_pct = disc_info["discount_pct"] + discount_amt = round(price * discount_pct / 100, 2) + after_disc = round(price - discount_amt, 2) + + _AGB = "Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen." + if disc_info["reason"] == "founder": + note = f"Gründer-Sonderkonditionen: {new_tier_label} kostenfrei als Dankeschön für deine Unterstützung als Gründer! {_AGB}" + elif disc_info["reason"] == "referred_by_founder": + note = f"Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft {discount_pct}% Rabatt. {_AGB}" + elif disc_info["reason"] == "referral": + note = f"Herzlichen Dank für deine Unterstützung! Für {disc_info['referral_count']} geworbene Freunde erhältst du {discount_pct}% Rabatt. {_AGB}" + else: + note = f"{_AGB} (Upgrade von {req.get('old_tier','Standard')} auf {new_tier_label})" + inv_number = _next_invoice_number(conn) conn.execute(""" INSERT INTO invoices (invoice_number, user_id, recipient_name, recipient_email, recipient_address, description, service_period, amount_net, discount_pct, discount_amount, amount_after_discount, tax_rate, tax_amount, amount_gross, notes) - VALUES (?,?,?,?,?,?,?,?,0,0,?,0,0,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?,?) """, ( inv_number, req["user_id"], req["name"], req["email"], billing_address, - description, period, price, price, price, - f"Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen. (Upgrade von {req.get('old_tier','Standard')} auf {new_tier_label})", + description, period, price, discount_pct, discount_amt, after_disc, after_disc, note, )) invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( diff --git a/backend/scheduler.py b/backend/scheduler.py index edb9963..19c293e 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -246,6 +246,49 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s ).fetchone() billing_address = row["billing_address"] if row else None + # Rabatt berechnen (inline, da kein Admin-Import möglich) + disc_row = conn.execute( + """SELECT u.is_founder, u.is_founder_pending, u.referred_by, + COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count + FROM users u WHERE u.id=?""", + (user["id"],) + ).fetchone() + discount_pct = 0 + discount_reason = None + referral_count = 0 + if disc_row: + referral_count = disc_row["referral_count"] + if disc_row["is_founder"] or disc_row["is_founder_pending"]: + discount_pct = 100 + discount_reason = "founder" + elif (disc_row["referred_by"] or 0) > 0: + ref = conn.execute( + "SELECT is_founder, is_founder_pending FROM users WHERE id=?", + (disc_row["referred_by"],) + ).fetchone() + if ref and (ref["is_founder"] or ref["is_founder_pending"]): + discount_pct = 50 + discount_reason = "referred_by_founder" + if not discount_reason: + for thr, pct in [(50, 50), (20, 30), (10, 20)]: + if referral_count >= thr: + discount_pct = pct + discount_reason = "referral" + break + + discount_amt = round(price * discount_pct / 100, 2) + after_disc = round(price - discount_amt, 2) + + _AGB = "Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen." + if discount_reason == "founder": + notes = f"Gründer-Sonderkonditionen: {tier_label} kostenfrei als Dankeschön für deine Unterstützung als Gründer! {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})" + elif discount_reason == "referred_by_founder": + notes = f"Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})" + elif discount_reason == "referral": + notes = f"Herzlichen Dank für deine Unterstützung! Für {referral_count} geworbene Freunde erhältst du {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})" + else: + notes = f"{_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})" + invoice_number = _next_invoice_number(conn) description = f"{tier_label} Jahresabo (Verlängerung)" conn.execute(""" @@ -253,12 +296,11 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s (invoice_number, user_id, recipient_name, recipient_email, recipient_address, description, service_period, amount_net, discount_pct, discount_amount, amount_after_discount, tax_rate, tax_amount, amount_gross, notes) - VALUES (?,?,?,?,?,?,?,?,0,0,?,0,0,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?,?) """, ( invoice_number, user["id"], user["name"], user["email"], billing_address, description, period, - price, price, price, - f"Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen. (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})", + price, discount_pct, discount_amt, after_disc, after_disc, notes, )) conn.execute( "INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)", diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 3a898ae..d3b5fe3 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3531,8 +3531,14 @@ window.Page_admin = (() => {
${_esc(r.email)}
${tierBadge(r.tier)} + ${r.discount_pct > 0 ? ` + ${r.discount_pct}% Rabatt` : ''} ${r.created_at?.slice(0,10) || ''}
+ ${r.discount_reason === 'founder' ? `
Gründer — kostenfrei
` : ''} + ${r.discount_reason === 'referred_by_founder' ? `
Von Gründer eingeladen
` : ''} + ${r.discount_reason === 'referral' ? `
${r.referral_count} Freunde geworben
` : ''} ${r.message ? `
@@ -3544,6 +3550,9 @@ window.Page_admin = (() => { - `).join('')} + +
+
+ ${TABS.map(t => ` + + `).join('')} +
+
- - -
`; _container.querySelector('#adm-tabs') diff --git a/backend/static/sw.js b/backend/static/sw.js index db77354..f648f9a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v972'; +const CACHE_VERSION = 'by-v973'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 8f3d639e340763bd66601c638c2ed4772f385bdc Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 12:49:04 +0200 Subject: [PATCH 030/113] Fix: Stornierte/Entwurfs-Rechnungen aus Quartalsbericht und Cashflow-Summen ausgeschlossen --- backend/routes/invoices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index de23157..28dcb21 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -414,7 +414,7 @@ def get_quarterly(year: int, q: int, admin=Depends(require_admin)): with db() as conn: rows = conn.execute( - "SELECT * FROM invoices WHERE created_at >= ? AND created_at <= ? ORDER BY id", + "SELECT * FROM invoices WHERE status IN ('paid','sent') AND created_at >= ? AND created_at <= ? ORDER BY id", (from_date, to_date + "T23:59:59Z") ).fetchall() From 2bbf3bc3f6791cb60e8649475fe638676ab6fc2c Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 12:53:26 +0200 Subject: [PATCH 031/113] =?UTF-8?q?Fix:=20CSV-Spalten=20korrigiert=20?= =?UTF-8?q?=E2=80=94=20Netto/Brutto=20getrennt,=20Zahlungseingang=20statt?= =?UTF-8?q?=20Erstellt=20(SW=20by-v974)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 8 +++++--- backend/static/sw.js | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/main.py b/backend/main.py index 3b3b3e5..fe2f332 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "973" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "974" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 6909290..c3be70d 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '973'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '974'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index f0de9f3..9559af1 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -4355,11 +4355,13 @@ window.Page_admin = (() => { const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : ''; const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; - const header = 'Nummer;Empfänger;E-Mail;Betrag (netto);Betrag (brutto);Status;Erstellt;Versendet;Bezahlt\n'; + const header = 'Nummer;Empfaenger;E-Mail;Rechnungsdatum;Leistungszeitraum;Nettobetrag;Bruttobetrag;Eingegangener Betrag;Status;Versendet am;Zahlungseingang\n'; const csvRows = data.invoices.map(inv => [inv.invoice_number, inv.recipient_name, inv.recipient_email || '', - fmtEur(inv.amount_gross), fmtEur(inv.amount_gross), inv.status, - fmtDate(inv.created_at), fmtDate(inv.sent_at), fmtDate(inv.paid_at) + fmtDate(inv.created_at), inv.service_period || '', + fmtEur(inv.amount_net), fmtEur(inv.amount_gross), + inv.paid_amount != null ? fmtEur(inv.paid_amount) : '', + inv.status, fmtDate(inv.sent_at), fmtDate(inv.paid_at) ].map(escape).join(';') ).join('\n'); diff --git a/backend/static/sw.js b/backend/static/sw.js index f648f9a..da7d824 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v973'; +const CACHE_VERSION = 'by-v974'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From cabb2fd6f7e1f51811e749848a2cae428e3d9d00 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 13:15:49 +0200 Subject: [PATCH 032/113] =?UTF-8?q?Fix:=20iOS=20Modal=20scrollIntoView=20b?= =?UTF-8?q?ei=20Tastatur;=20CSV=20Stornierte=20mit=200=E2=82=AC=20+=20Stor?= =?UTF-8?q?nonummer=20(SW=20by-v975)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/routes/invoices.py | 11 +++++++---- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 24 +++++++++++++++--------- backend/static/js/ui.js | 16 +++++++++++++--- backend/static/sw.js | 2 +- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/backend/main.py b/backend/main.py index fe2f332..1ff02d8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "974" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "975" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 28dcb21..17b6083 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -413,14 +413,17 @@ def get_quarterly(year: int, q: int, admin=Depends(require_admin)): period = f"Q{q} {year} ({labels[q]} – {ends[q]})" with db() as conn: + # Alle Rechnungen außer Entwürfe — Stornierte bleiben mit 0€ für lückenlose Nummerierung rows = conn.execute( - "SELECT * FROM invoices WHERE status IN ('paid','sent') AND created_at >= ? AND created_at <= ? ORDER BY id", + "SELECT * FROM invoices WHERE status != 'draft' AND created_at >= ? AND created_at <= ? ORDER BY id", (from_date, to_date + "T23:59:59Z") ).fetchall() - total_net = sum(r["amount_net"] for r in rows) - total_tax = sum(r["tax_amount"] for r in rows) - total_gross = sum(r["amount_gross"] for r in rows) + # Summen nur für paid/sent (Stornierte zählen nicht zum Umsatz) + active = [r for r in rows if r["status"] in ("paid", "sent")] + total_net = sum(r["amount_net"] for r in active) + total_tax = sum(r["tax_amount"] for r in active) + total_gross = sum(r["amount_gross"] for r in active) return { "period": period, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index c3be70d..314d227 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '974'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '975'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 9559af1..9dfc165 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -4355,15 +4355,21 @@ window.Page_admin = (() => { const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : ''; const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; - const header = 'Nummer;Empfaenger;E-Mail;Rechnungsdatum;Leistungszeitraum;Nettobetrag;Bruttobetrag;Eingegangener Betrag;Status;Versendet am;Zahlungseingang\n'; - const csvRows = data.invoices.map(inv => - [inv.invoice_number, inv.recipient_name, inv.recipient_email || '', - fmtDate(inv.created_at), inv.service_period || '', - fmtEur(inv.amount_net), fmtEur(inv.amount_gross), - inv.paid_amount != null ? fmtEur(inv.paid_amount) : '', - inv.status, fmtDate(inv.sent_at), fmtDate(inv.paid_at) - ].map(escape).join(';') - ).join('\n'); + const header = 'Nummer;Stornonummer;Empfaenger;E-Mail;Rechnungsdatum;Leistungszeitraum;Nettobetrag;Bruttobetrag;Eingegangener Betrag;Status;Versendet am;Zahlungseingang\n'; + const csvRows = data.invoices.map(inv => { + const cancelled = inv.status === 'cancelled'; + return [ + inv.invoice_number, + inv.cancellation_number || '', + inv.recipient_name, inv.recipient_email || '', + fmtDate(inv.created_at), inv.service_period || '', + cancelled ? '0.00' : fmtEur(inv.amount_net), + cancelled ? '0.00' : fmtEur(inv.amount_gross), + cancelled ? '0.00' : (inv.paid_amount != null ? fmtEur(inv.paid_amount) : ''), + cancelled ? 'Storniert' : inv.status, + fmtDate(inv.sent_at), fmtDate(inv.paid_at) + ].map(escape).join(';'); + }).join('\n'); const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index a0b69b1..0729963 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -83,7 +83,7 @@ const UI = (() => { document.getElementById('modal-container').appendChild(overlay); document.documentElement.classList.add('modal-open'); - // Tastatur auf Mobilgeräten: Modal nach oben schieben wenn Keyboard erscheint + // Tastatur auf Mobilgeräten: Modal nach oben schieben + fokussiertes Feld einblenden let _vvCleanup = null; const vv = window.visualViewport; if (vv) { @@ -100,16 +100,26 @@ const UI = (() => { }; } - _current = { overlay, onClose, _vvCleanup }; + // Fokussiertes Feld in den sichtbaren Bereich scrollen (iOS) + const _onFocusin = e => { + const el = e.target; + if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') { + setTimeout(() => el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), 320); + } + }; + overlay.addEventListener('focusin', _onFocusin); + + _current = { overlay, onClose, _vvCleanup, _onFocusin }; return overlay.querySelector('.modal'); } function close() { if (!_current) return; - const { onClose, _vvCleanup } = _current; + const { onClose, _vvCleanup, _onFocusin } = _current; onClose?.(); _vvCleanup?.(); + if (_onFocusin) _current.overlay.removeEventListener('focusin', _onFocusin); _current.overlay.remove(); document.documentElement.classList.remove('modal-open'); _current = null; diff --git a/backend/static/sw.js b/backend/static/sw.js index da7d824..402bf98 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v974'; +const CACHE_VERSION = 'by-v975'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From b10b3140eb1b724a1b63258eed96f3cd5d6c4ae9 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 13:18:32 +0200 Subject: [PATCH 033/113] =?UTF-8?q?Fix:=20Stornorechnung=20referenziert=20?= =?UTF-8?q?Originalnummer=20+=20Datum=20(=C2=A714=20UStG=20Pflicht)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/invoices.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 17b6083..5b2b272 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -676,10 +676,18 @@ async def cancel_invoice(invoice_id: int, data: CancelBody, admin=Depends(requir invoice = _row_to_dict(row) # Storno-PDF: invoice-Dict als Stornobeleg aufbereiten + orig_date = (invoice.get("created_at") or "")[:10] + try: + from datetime import datetime as _dt + y, m, d = orig_date.split("-") + orig_date_de = f"{d}.{m}.{y}" + except Exception: + orig_date_de = orig_date + storno_invoice = dict(invoice) storno_invoice["invoice_number"] = cancellation_number storno_invoice["notes"] = ( - f"Stornierung zu Rechnung {invoice['invoice_number']}\n" + f"Stornorechnung zu Rechnung {invoice['invoice_number']} vom {orig_date_de}\n" f"Grund: {data.reason}" ) storno_invoice["amount_net"] = -invoice["amount_net"] From 6104132714ac50f119cb7b61bf3e1ec8e4e07a53 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 13:27:05 +0200 Subject: [PATCH 034/113] =?UTF-8?q?Feat:=20Quartalsbericht=20=E2=80=94=20S?= =?UTF-8?q?tornozeilen=20mit=20Minusbetr=C3=A4gen,=20nach=20Datum=20sortie?= =?UTF-8?q?rt,=20Summen=20netten=20sich=20heraus=20(SW=20by-v976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/routes/invoices.py | 71 ++++++++++++++++++++++++++------ backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 36 ++++++++-------- backend/static/sw.js | 2 +- 5 files changed, 81 insertions(+), 32 deletions(-) diff --git a/backend/main.py b/backend/main.py index 1ff02d8..bd5662d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "975" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "976" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 5b2b272..07ac400 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -413,25 +413,72 @@ def get_quarterly(year: int, q: int, admin=Depends(require_admin)): period = f"Q{q} {year} ({labels[q]} – {ends[q]})" with db() as conn: - # Alle Rechnungen außer Entwürfe — Stornierte bleiben mit 0€ für lückenlose Nummerierung + # Alle Rechnungen außer Entwürfe im Quartal (nach Ausstellungsdatum) rows = conn.execute( - "SELECT * FROM invoices WHERE status != 'draft' AND created_at >= ? AND created_at <= ? ORDER BY id", + "SELECT * FROM invoices WHERE status != 'draft' AND created_at >= ? AND created_at <= ? ORDER BY created_at ASC", (from_date, to_date + "T23:59:59Z") ).fetchall() - # Summen nur für paid/sent (Stornierte zählen nicht zum Umsatz) - active = [r for r in rows if r["status"] in ("paid", "sent")] - total_net = sum(r["amount_net"] for r in active) - total_tax = sum(r["tax_amount"] for r in active) - total_gross = sum(r["amount_gross"] for r in active) + # Stornorechnungen die im Quartal ausgestellt wurden (cancelled_at im Zeitraum, + # auch wenn die Originalrechnung außerhalb des Quartals liegt) + storno_rows = conn.execute( + "SELECT * FROM invoices WHERE status = 'cancelled' AND cancelled_at >= ? AND cancelled_at <= ?", + (from_date, to_date + "T23:59:59Z") + ).fetchall() + + # Buchungseinträge aufbauen + entries = [] + + # Originalrechnungen (paid, sent — mit positivem Betrag) + for r in rows: + d = _row_to_dict(r) + if d["status"] in ("paid", "sent"): + entries.append(d) + elif d["status"] == "cancelled": + # Originalrechnung erscheint mit positivem Betrag (wurde ausgestellt) + entries.append(d) + + # Stornozeilen: negative Beträge, Datum = cancelled_at, Nummer = cancellation_number + storno_ids_already = {r["id"] for r in rows} + for r in storno_rows: + d = _row_to_dict(r) + storno_entry = { + "invoice_number": d["cancellation_number"] or f"ST-{d['invoice_number']}", + "recipient_name": d["recipient_name"], + "recipient_email": d["recipient_email"], + "created_at": d["cancelled_at"], + "service_period": d["service_period"], + "amount_net": -round(d["amount_net"], 2), + "tax_amount": -round(d.get("tax_amount") or 0, 2), + "amount_gross": -round(d["amount_gross"], 2), + "paid_amount": None, + "status": "storno", + "sent_at": None, + "paid_at": None, + "cancellation_number": d["cancellation_number"], + "notes": f"Storno zu {d['invoice_number']}", + } + entries.append(storno_entry) + # Wenn Original NICHT im Quartal aber Storno schon → Original trotzdem zeigen + if r["id"] not in storno_ids_already: + orig = _row_to_dict(r) + entries.append(orig) + + # Nach Datum sortieren + entries.sort(key=lambda e: (e.get("created_at") or "")) + + # Summen: Originalrechnungen positiv + Stornos negativ + total_net = sum(e["amount_net"] for e in entries if e["status"] != "cancelled") + total_tax = sum(e.get("tax_amount") or 0 for e in entries if e["status"] != "cancelled") + total_gross = sum(e["amount_gross"] for e in entries if e["status"] != "cancelled") return { - "period": period, - "invoices": [_row_to_dict(r) for r in rows], - "total_net": round(total_net, 2), - "total_tax": round(total_tax, 2), + "period": period, + "invoices": entries, + "total_net": round(total_net, 2), + "total_tax": round(total_tax, 2), "total_gross": round(total_gross, 2), - "count": len(rows), + "count": len(entries), } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 314d227..36b0ba0 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '975'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '976'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 9dfc165..cf24474 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -4355,21 +4355,19 @@ window.Page_admin = (() => { const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : ''; const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; - const header = 'Nummer;Stornonummer;Empfaenger;E-Mail;Rechnungsdatum;Leistungszeitraum;Nettobetrag;Bruttobetrag;Eingegangener Betrag;Status;Versendet am;Zahlungseingang\n'; - const csvRows = data.invoices.map(inv => { - const cancelled = inv.status === 'cancelled'; - return [ + const statusLabel = { paid: 'Bezahlt', sent: 'Versendet', cancelled: 'Storniert (Original)', storno: 'Stornorechnung' }; + const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Nettobetrag;Bruttobetrag;Eingegangener Betrag;Status;Versendet am;Zahlungseingang\n'; + const csvRows = data.invoices.map(inv => [ inv.invoice_number, - inv.cancellation_number || '', inv.recipient_name, inv.recipient_email || '', fmtDate(inv.created_at), inv.service_period || '', - cancelled ? '0.00' : fmtEur(inv.amount_net), - cancelled ? '0.00' : fmtEur(inv.amount_gross), - cancelled ? '0.00' : (inv.paid_amount != null ? fmtEur(inv.paid_amount) : ''), - cancelled ? 'Storniert' : inv.status, + fmtEur(inv.amount_net), + fmtEur(inv.amount_gross), + inv.paid_amount != null ? fmtEur(inv.paid_amount) : '', + statusLabel[inv.status] || inv.status, fmtDate(inv.sent_at), fmtDate(inv.paid_at) - ].map(escape).join(';'); - }).join('\n'); + ].map(escape).join(';') + ).join('\n'); const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); @@ -4398,15 +4396,19 @@ window.Page_admin = (() => { } const _fmtE = v => v != null ? Number(v).toLocaleString('de-DE',{minimumFractionDigits:2}) + ' €' : '—'; const _fmtD = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '—'; - const sL = { draft:'Entwurf',sent:'Versendet',paid:'Bezahlt',cancelled:'Storniert' }; - const rows2 = data.invoices.map((inv, i) => ` + const sL = { draft:'Entwurf', sent:'Versendet', paid:'Bezahlt', cancelled:'Storniert (Orig.)', storno:'Stornorechnung' }; + const rows2 = data.invoices.map((inv, i) => { + const isStorno = inv.status === 'storno'; + const amtColor = isStorno ? 'color:var(--c-danger)' : (inv.amount_gross < 0 ? 'color:var(--c-danger)' : ''); + return ` - ${_esc(inv.invoice_number)} + ${_esc(inv.invoice_number)} ${_esc(inv.recipient_name)} - ${_fmtE(inv.amount_gross)} - ${sL[inv.status]||inv.status} + ${_fmtE(inv.amount_gross)} + ${sL[inv.status]||inv.status} ${_fmtD(inv.created_at)} - `).join(''); + `; + }).join(''); resultEl.innerHTML = `
${_esc(data.period || `Q${q} ${year}`)} — ${data.count} Rechnung(en) · Brutto: ${_fmtE(data.total_gross)} diff --git a/backend/static/sw.js b/backend/static/sw.js index 402bf98..645709c 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v975'; +const CACHE_VERSION = 'by-v976'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From ed6dd8da13549fec6a45e32d4ea77303f08b9ae4 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 13:38:08 +0200 Subject: [PATCH 035/113] Fix: Quartalssumme korrekt (alle inkl. Storno), Netto ausgeblendet (SW by-v977) --- backend/main.py | 2 +- backend/routes/invoices.py | 18 ++++++++---------- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 5 ++--- backend/static/sw.js | 2 +- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/backend/main.py b/backend/main.py index bd5662d..8bcb55c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "976" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "977" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 07ac400..cb7bd90 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -467,18 +467,16 @@ def get_quarterly(year: int, q: int, admin=Depends(require_admin)): # Nach Datum sortieren entries.sort(key=lambda e: (e.get("created_at") or "")) - # Summen: Originalrechnungen positiv + Stornos negativ - total_net = sum(e["amount_net"] for e in entries if e["status"] != "cancelled") - total_tax = sum(e.get("tax_amount") or 0 for e in entries if e["status"] != "cancelled") - total_gross = sum(e["amount_gross"] for e in entries if e["status"] != "cancelled") + # Summen: alle Einträge — Storno (-) und Original (+) heben sich gegenseitig auf + total_gross = sum(e.get("amount_gross") or 0 for e in entries) + total_tax = sum(e.get("tax_amount") or 0 for e in entries) return { - "period": period, - "invoices": entries, - "total_net": round(total_net, 2), - "total_tax": round(total_tax, 2), - "total_gross": round(total_gross, 2), - "count": len(entries), + "period": period, + "invoices": entries, + "total_tax": round(total_tax, 2), + "total_gross": round(total_gross, 2), + "count": len(entries), } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 36b0ba0..4fb0b34 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '976'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '977'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index cf24474..2d71fb6 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -4356,12 +4356,11 @@ window.Page_admin = (() => { const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; const statusLabel = { paid: 'Bezahlt', sent: 'Versendet', cancelled: 'Storniert (Original)', storno: 'Stornorechnung' }; - const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Nettobetrag;Bruttobetrag;Eingegangener Betrag;Status;Versendet am;Zahlungseingang\n'; + const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Betrag;Eingegangener Betrag;Status;Versendet am;Zahlungseingang\n'; const csvRows = data.invoices.map(inv => [ inv.invoice_number, inv.recipient_name, inv.recipient_email || '', fmtDate(inv.created_at), inv.service_period || '', - fmtEur(inv.amount_net), fmtEur(inv.amount_gross), inv.paid_amount != null ? fmtEur(inv.paid_amount) : '', statusLabel[inv.status] || inv.status, @@ -4411,7 +4410,7 @@ window.Page_admin = (() => { }).join(''); resultEl.innerHTML = `
- ${_esc(data.period || `Q${q} ${year}`)} — ${data.count} Rechnung(en) · Brutto: ${_fmtE(data.total_gross)} + ${_esc(data.period || `Q${q} ${year}`)} — ${data.count} Buchung(en) · Summe: ${_fmtE(data.total_gross)}
diff --git a/backend/static/sw.js b/backend/static/sw.js index 645709c..0c0b8f3 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v976'; +const CACHE_VERSION = 'by-v977'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From f0f767e466aee8dac64f0048d3d6f0834aed913d Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 13:43:17 +0200 Subject: [PATCH 036/113] =?UTF-8?q?Fix:=20iOS=20Tastatur=20=E2=80=94=20mod?= =?UTF-8?q?al-body=20maxHeight=20begrenzen=20+=20manueller=20scroll=20stat?= =?UTF-8?q?t=20scrollIntoView=20(SW=20by-v978)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/ui.js | 28 +++++++++++++++++++++------- backend/static/sw.js | 2 +- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8bcb55c..419babe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "977" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "978" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4fb0b34..cb220e1 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '977'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '978'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 0729963..9da043f 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -83,13 +83,19 @@ const UI = (() => { document.getElementById('modal-container').appendChild(overlay); document.documentElement.classList.add('modal-open'); - // Tastatur auf Mobilgeräten: Modal nach oben schieben + fokussiertes Feld einblenden + // Tastatur auf Mobilgeräten: Modal-Höhe begrenzen + fokussiertes Feld scrollen let _vvCleanup = null; const vv = window.visualViewport; + const modal = overlay.querySelector('.modal'); if (vv) { const adjust = () => { - const kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); - overlay.style.paddingBottom = (kb + 16) + 'px'; + const visible = vv.height; + const offset = vv.offsetTop; + const kb = Math.max(0, window.innerHeight - visible - offset); + // Overlay-Padding damit Modal nach oben rückt + overlay.style.paddingBottom = (kb + 8) + 'px'; + // Modal-Höhe hart begrenzen damit modal-body scrollbar bleibt + if (modal) modal.style.maxHeight = (visible - 24) + 'px'; }; vv.addEventListener('resize', adjust); vv.addEventListener('scroll', adjust); @@ -97,15 +103,23 @@ const UI = (() => { vv.removeEventListener('resize', adjust); vv.removeEventListener('scroll', adjust); overlay.style.paddingBottom = ''; + if (modal) modal.style.maxHeight = ''; }; } - // Fokussiertes Feld in den sichtbaren Bereich scrollen (iOS) + // Fokussiertes Feld innerhalb modal-body scrollen (iOS scrollIntoView + // arbeitet nicht zuverlässig in overflow-Containern) const _onFocusin = e => { const el = e.target; - if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') { - setTimeout(() => el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), 320); - } + if (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' && el.tagName !== 'SELECT') return; + setTimeout(() => { + const body = el.closest('.modal-body'); + if (!body) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; } + const elBottom = el.getBoundingClientRect().bottom; + const vvBottom = vv ? (vv.offsetTop + vv.height) : window.innerHeight; + const gap = elBottom - vvBottom + 56; // 56px Puffer über Tastatur + if (gap > 0) body.scrollTop += gap; + }, 380); }; overlay.addEventListener('focusin', _onFocusin); diff --git a/backend/static/sw.js b/backend/static/sw.js index 0c0b8f3..fa39e6d 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v977'; +const CACHE_VERSION = 'by-v978'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 49e01683ad1d16ec9788a5c70b123a30feb47f2e Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 13:51:26 +0200 Subject: [PATCH 037/113] =?UTF-8?q?Fix:=20'F=C3=A4llig=20bis'=20Umlaut=20k?= =?UTF-8?q?orrigiert=20im=20PDF-Info-Block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/invoices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index cb7bd90..4aceda3 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -160,7 +160,7 @@ def _generate_pdf(invoice, items) -> bytes: info_rows = [ ("Rechnungsnummer", invoice["invoice_number"]), ("Datum", fdate(invoice.get("created_at", ""))), - ("Faellig bis", due_date), + ("Fällig bis", due_date), ] if invoice.get("service_period"): info_rows.append(("Leistungszeitraum", invoice["service_period"])) From 04d8ed153bf013e77d6be38da890e99fe0647b1b Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 13:56:13 +0200 Subject: [PATCH 038/113] =?UTF-8?q?UX:=20Neue=20Rechnung=20=E2=80=94=20Hin?= =?UTF-8?q?weis=20'nicht=20f=C3=BCr=20Abos',=20neutraler=20Placeholder,=20?= =?UTF-8?q?passender=20Notiz-Default=20(SW=20by-v979)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 12 ++++++++++-- backend/static/sw.js | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/backend/main.py b/backend/main.py index 419babe..d1430df 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "978" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "979" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index cb220e1..91f4a6d 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '978'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '979'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 2d71fb6..a3b2b91 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3884,6 +3884,14 @@ window.Page_admin = (() => { body: `
+ ${!isEdit && !p.recipient_name ? ` +
+ Diese Rechnung ist für sonstige Leistungen (Beratung, Einmalleistung etc.).
+ Für Abo-Verlängerungen bitte den Button „Rechnung erstellen" in der Upgrades-Liste verwenden. +
` : ''} +
@@ -3912,7 +3920,7 @@ window.Page_admin = (() => {
@@ -3948,7 +3956,7 @@ window.Page_admin = (() => { + placeholder="Interne Notiz / Zahlungshinweis">${_esc(p.notes || (!isEdit && !p.recipient_name ? 'Zahlbar innerhalb von 14 Tagen ab Rechnungsdatum.' : ''))}
diff --git a/backend/static/sw.js b/backend/static/sw.js index fa39e6d..da47cbd 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v978'; +const CACHE_VERSION = 'by-v979'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 78f30773171402f27c7941c77685193d96b19cc2 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 14:01:45 +0200 Subject: [PATCH 039/113] UX: Freischalten zeigt Rechnungsentwurf-Nummer im Toast + Confirm-Hinweis (SW by-v980) --- backend/main.py | 2 +- backend/routes/admin.py | 6 ++++-- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 11 +++++++++-- backend/static/sw.js | 2 +- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/backend/main.py b/backend/main.py index d1430df..c7316a4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "979" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "980" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 86abd4b..0ff5241 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1275,12 +1275,13 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)): logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}") # Offene Rechnungen (sent/draft) des alten Tiers stornieren + neuen Entwurf anlegen + inv_number = None try: - await _handle_upgrade_invoices(req, tier_label) + inv_number = await _handle_upgrade_invoices(req, tier_label) except Exception as e: logger.warning(f"Upgrade-Rechnungslogik fehlgeschlagen für {req['name']}: {e}") - return {"ok": True, "tier": req["tier"], "user": req["name"]} + return {"ok": True, "tier": req["tier"], "user": req["name"], "invoice_number": inv_number} def _get_discount_info(conn, user_id: int) -> dict: @@ -1379,6 +1380,7 @@ async def _handle_upgrade_invoices(req: dict, new_tier_label: str): ) logger.info(f"Neuer Rechnungsentwurf {inv_number} für {req['email']} nach Upgrade auf {new_tier_label}") + return inv_number # ------------------------------------------------------------------ diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 91f4a6d..8c8ee55 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '979'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '980'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index a3b2b91..a02e1e1 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3610,7 +3610,7 @@ window.Page_admin = (() => { const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier; const ok = await UI.modal.confirm({ title: `${name} auf ${tierLabel} freischalten?`, - message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.`, + message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.\n\nFalls noch keine Rechnung gesendet wurde, wird ein Entwurf automatisch angelegt.`, confirmText: 'Freischalten', danger: false, }); @@ -3619,7 +3619,14 @@ window.Page_admin = (() => { btn.textContent = '…'; try { const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`); - UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`); + if (res.invoice_number) { + UI.toast.success( + `${res.user} freigeschaltet · Entwurf ${res.invoice_number} unter Rechnungen versenden`, + 6000 + ); + } else { + UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`); + } _renderTab(); _renderActionItems(); } catch (e) { diff --git a/backend/static/sw.js b/backend/static/sw.js index da47cbd..76366b9 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v979'; +const CACHE_VERSION = 'by-v980'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 1a8716b0b222cca35d603022a62099cc942a8c8c Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 15:41:55 +0200 Subject: [PATCH 040/113] =?UTF-8?q?Perf:=20Priorit=C3=A4ts-Seiten=20pre-ca?= =?UTF-8?q?che=20+=20Stale-While-Revalidate=20+=20Background-Warm-up=20(SW?= =?UTF-8?q?=20by-v981)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 15 ++++++++++++++- backend/static/sw.js | 39 +++++++++++++++++++++++++++++++++------ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/backend/main.py b/backend/main.py index c7316a4..989217e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "980" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "981" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 8c8ee55..f450de3 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '980'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '981'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen @@ -1140,6 +1140,19 @@ const App = (() => { window.App = App; // Worlds kann App.navigate() aufrufen // App starten +// Prioritäts-Seiten im Hintergrund vorladen (3s nach Start, damit Hauptinhalt nicht blockiert) +window.addEventListener('load', () => { + setTimeout(() => { + if (!('caches' in window)) return; + ['admin','erste-hilfe','diary','map','walks','routes','poison','lost'].forEach(page => { + const key = `Page_${page.replace(/-/g,'_')}`; + if (!window[key]) { + fetch(`/js/pages/${page}.js?v=${APP_VER}`).catch(() => {}); + } + }); + }, 3000); +}); + document.addEventListener('DOMContentLoaded', () => { App.init(); if (IS_STAGING) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 76366b9..b78b525 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,16 +3,28 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v980'; +const CACHE_VERSION = 'by-v981'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache +// Prioritäts-Seiten: werden pre-gecacht + Stale-While-Revalidate +const PRIORITY_PAGES = [ + '/js/pages/admin.js', + '/js/pages/erste-hilfe.js', + '/js/pages/diary.js', + '/js/pages/map.js', + '/js/pages/walks.js', + '/js/pages/routes.js', + '/js/pages/poison.js', + '/js/pages/lost.js', +]; + // index.html wird NICHT pre-gecacht (immer Network-First) const STATIC_ASSETS = [ - '/css/design-system.css?v=700', - '/css/layout.css?v=700', - '/css/components.css?v=700', + '/css/design-system.css?v=980', + '/css/layout.css?v=980', + '/css/components.css?v=980', '/icons/phosphor.svg', '/js/api.js', '/js/ui.js', @@ -22,6 +34,7 @@ const STATIC_ASSETS = [ '/css/MarkerCluster.Default.css', '/manifest.json', '/icons/icon-192.png', + ...PRIORITY_PAGES, ]; // ---------------------------------------------------------- @@ -297,7 +310,21 @@ self.addEventListener('fetch', event => { return; } - // CSS, Core-JS + Seiten-Module: immer Network-First — damit iOS nie veraltete Versionen cached + // Prioritäts-Seiten: Stale-While-Revalidate — sofort aus Cache, im Hintergrund aktualisieren + if (PRIORITY_PAGES.includes(url.pathname)) { + event.respondWith( + caches.open(CACHE_STATIC).then(async cache => { + const cached = await cache.match(event.request, { ignoreSearch: true }); + const netFetch = fetch(event.request) + .then(res => { if (res.ok) cache.put(event.request, res.clone()); return res; }) + .catch(() => null); + return cached ?? (await netFetch) ?? new Response('', { status: 503 }); + }) + ); + return; + } + + // CSS, Core-JS + übrige Seiten-Module: Network-First — damit iOS nie veraltete Versionen cached if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/') || url.pathname.startsWith('/js/app.js') || url.pathname.startsWith('/js/ui.js') || url.pathname.startsWith('/js/api.js') || url.pathname.startsWith('/js/worlds.js')) { @@ -310,7 +337,7 @@ self.addEventListener('fetch', event => { } return response; }) - .catch(() => caches.match(event.request) + .catch(() => caches.match(event.request, { ignoreSearch: true }) .then(cached => cached || new Response('', { status: 503 }))) ); return; From 68fd9c0e380a37925a963d456534592090f36285 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 15:50:02 +0200 Subject: [PATCH 041/113] =?UTF-8?q?Fix:=20En-Dash=20in=20PDF=20durch=20Bin?= =?UTF-8?q?destrich=20ersetzen=20+=20=5Fs()=20Sanitizer=20f=C3=BCr=20alle?= =?UTF-8?q?=20Texteingaben=20(SW=20by-v982)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/routes/admin.py | 6 +++--- backend/routes/invoices.py | 28 ++++++++++++++++++++-------- backend/scheduler.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 2 +- backend/static/sw.js | 2 +- 7 files changed, 28 insertions(+), 16 deletions(-) diff --git a/backend/main.py b/backend/main.py index 989217e..9ba2d37 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "981" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "982" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 0ff5241..c0ec221 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1339,7 +1339,7 @@ async def _handle_upgrade_invoices(req: dict, new_tier_label: str): price = {"pro": 29.00, "breeder": 49.00}.get(tier, 29.00) today = datetime.now(_TZ).date() end_date = today.replace(year=today.year + 1) - timedelta(days=1) - period = f"{today.strftime('%d.%m.%Y')} – {end_date.strftime('%d.%m.%Y')}" + period = f"{today.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}" description = f"{new_tier_label} Jahresabo" billing = conn.execute( @@ -1486,7 +1486,7 @@ async def send_quarterly_report(data: QuarterlyReportBody, user=Depends(require_ count_sent = sum(1 for r in rows if r["status"] == "sent") subject_stb = ( - f"Ban Yaro – Rechnungen Q{data.quarter}/{data.year} " + f"Ban Yaro - Rechnungen Q{data.quarter}/{data.year} " f"({start} bis {end})" ) body_stb = ( @@ -1559,7 +1559,7 @@ async def send_quarterly_report(data: QuarterlyReportBody, user=Depends(require_ "sent_to": data.email, "year": data.year, "quarter": data.quarter, - "period": f"{start} – {end}", + "period": f"{start} - {end}", "count": len(rows), "count_paid": count_paid, "count_sent": count_sent, diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 4aceda3..784b5e9 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -82,6 +82,18 @@ def _generate_pdf(invoice, items) -> bytes: LG = (245, 245, 245) WH = (255, 255, 255) + def _s(text) -> str: + """Nicht-Latin1-Zeichen ersetzen bevor sie an fpdf Helvetica übergeben werden.""" + if not text: + return "" + return (str(text) + .replace("–", "-").replace("—", "-") # En/Em-Dash + .replace("‘", "'").replace("’", "'") # Typogr. Anf.zeichen + .replace("“", '"').replace("”", '"') + .replace("…", "...").replace("·", ".") + .replace("€", "EUR") # € falls doch + ) + def eur(v: float) -> str: s = f"{v:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") return f"{s} EUR" @@ -144,17 +156,17 @@ def _generate_pdf(invoice, items) -> bytes: pdf.set_xy(20, 52) pdf.set_font("Helvetica", "B", 10) pdf.set_text_color(*DK) - pdf.cell(85, 5.5, invoice["recipient_name"], new_x="LMARGIN", new_y="NEXT") + pdf.cell(85, 5.5, _s(invoice["recipient_name"]), new_x="LMARGIN", new_y="NEXT") pdf.set_font("Helvetica", "", 10) if invoice.get("recipient_address"): for line in str(invoice["recipient_address"]).split("\n"): if line.strip(): pdf.set_x(20) - pdf.cell(85, 5, line.strip(), new_x="LMARGIN", new_y="NEXT") + pdf.cell(85, 5, _s(line.strip()), new_x="LMARGIN", new_y="NEXT") pdf.set_x(20) pdf.set_font("Helvetica", "", 8.5) pdf.set_text_color(*GY) - pdf.cell(85, 5, invoice["recipient_email"]) + pdf.cell(85, 5, _s(invoice["recipient_email"])) # ── Info-Block rechts ───────────────────────────────────────── info_rows = [ @@ -163,7 +175,7 @@ def _generate_pdf(invoice, items) -> bytes: ("Fällig bis", due_date), ] if invoice.get("service_period"): - info_rows.append(("Leistungszeitraum", invoice["service_period"])) + info_rows.append(("Leistungszeitraum", _s(invoice["service_period"]))) y_info = 52 for lbl, val in info_rows: @@ -204,7 +216,7 @@ def _generate_pdf(invoice, items) -> bytes: for i, item in enumerate(items): pdf.set_fill_color(*(LG if i % 2 == 0 else WH)) qty = f"{item['quantity']:.2f}".rstrip("0").rstrip(".") - pdf.cell(CW[0], 7, f" {str(item['description'])[:64]}", border="B", fill=True) + pdf.cell(CW[0], 7, f" {_s(str(item['description']))[:64]}", border="B", fill=True) pdf.cell(CW[1], 7, qty, border="B", fill=True, align="C") pdf.cell(CW[2], 7, eur(item["unit_price"]), border="B", fill=True, align="R") pdf.cell(CW[3], 7, eur(item["total"]), border="B", fill=True, align="R", @@ -240,7 +252,7 @@ def _generate_pdf(invoice, items) -> bytes: pdf.set_x(20) pdf.set_font("Helvetica", "I", 8.5) pdf.set_text_color(*GY) - pdf.multi_cell(W, 5, "Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet.") + pdf.multi_cell(W, 5, _s("Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet.") # ── Zahlungsinfo-Box ────────────────────────────────────────── pdf.ln(5) @@ -276,7 +288,7 @@ def _generate_pdf(invoice, items) -> bytes: pdf.set_x(20) pdf.set_font("Helvetica", "I", 9) pdf.set_text_color(*GY) - pdf.multi_cell(W, 5, str(invoice["notes"])) + pdf.multi_cell(W, 5, _s(str(invoice["notes"]))) # ── Footer (fixiert auf Seite 1, kein auto-break) ───────────── pdf.set_auto_page_break(False) @@ -410,7 +422,7 @@ def get_quarterly(year: int, q: int, admin=Depends(require_admin)): labels = {1: "01.01.", 2: "01.04.", 3: "01.07.", 4: "01.10."} ends = {1: "31.03.", 2: "30.06.", 3: "30.09.", 4: "31.12."} - period = f"Q{q} {year} ({labels[q]} – {ends[q]})" + period = f"Q{q} {year} ({labels[q]} - {ends[q]})" with db() as conn: # Alle Rechnungen außer Entwürfe im Quartal (nach Ausstellungsdatum) diff --git a/backend/scheduler.py b/backend/scheduler.py index 19c293e..91e8163 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -226,7 +226,7 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s # Verlängerungszeitraum: Folgetag nach Ablauf bis +1 Jahr start = expires + timedelta(days=1) end = start.replace(year=start.year + 1) - timedelta(days=1) - period = f"{start.strftime('%d.%m.%Y')} – {end.strftime('%d.%m.%Y')}" + period = f"{start.strftime('%d.%m.%Y')} - {end.strftime('%d.%m.%Y')}" with db() as conn: # Nur anlegen wenn noch kein Entwurf/offener Eintrag für diesen User + Zeitraum diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f450de3..b93c518 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '981'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '982'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index a02e1e1..81dc438 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3646,7 +3646,7 @@ window.Page_admin = (() => { const _now = new Date(); const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1); const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`; - const _period = `${_fmt(_now)} – ${_fmt(_end)}`; + const _period = `${_fmt(_now)} - ${_fmt(_end)}`; function _discountNote(reason, count, pct, tierLabel) { const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.'; diff --git a/backend/static/sw.js b/backend/static/sw.js index b78b525..8136c8e 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v981'; +const CACHE_VERSION = 'by-v982'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From d0b0e2c671646f71562eef6645e764187065ba1d Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 15:53:13 +0200 Subject: [PATCH 042/113] =?UTF-8?q?Fix:=20SW-Install-Fehler=20=E2=80=94=20?= =?UTF-8?q?PRIORITY=5FPAGES=20nicht-blockierend=20cachen,=20kein=20addAll(?= =?UTF-8?q?)=20(SW=20by-v983)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/sw.js | 44 +++++++++++++++++----------------------- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/backend/main.py b/backend/main.py index 9ba2d37..da0e0ae 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "982" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "983" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index b93c518..b33aa1b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '982'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '983'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/sw.js b/backend/static/sw.js index 8136c8e..6fb938c 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,12 +3,12 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v982'; +const CACHE_VERSION = 'by-v983'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache -// Prioritäts-Seiten: werden pre-gecacht + Stale-While-Revalidate +// Prioritäts-Seiten: werden nach Install im Hintergrund gecacht (nicht blockierend) const PRIORITY_PAGES = [ '/js/pages/admin.js', '/js/pages/erste-hilfe.js', @@ -22,9 +22,9 @@ const PRIORITY_PAGES = [ // index.html wird NICHT pre-gecacht (immer Network-First) const STATIC_ASSETS = [ - '/css/design-system.css?v=980', - '/css/layout.css?v=980', - '/css/components.css?v=980', + '/css/design-system.css?v=982', + '/css/layout.css?v=982', + '/css/components.css?v=982', '/icons/phosphor.svg', '/js/api.js', '/js/ui.js', @@ -34,7 +34,6 @@ const STATIC_ASSETS = [ '/css/MarkerCluster.Default.css', '/manifest.json', '/icons/icon-192.png', - ...PRIORITY_PAGES, ]; // ---------------------------------------------------------- @@ -175,13 +174,22 @@ function _cacheMark(pathname) { // INSTALL — App Shell cachen // ---------------------------------------------------------- self.addEventListener('install', event => { - self.skipWaiting(); // Sofort übernehmen — kein Warten auf Cache-Aufbau + self.skipWaiting(); event.waitUntil( caches.open(CACHE_STATIC) .then(cache => cache.addAll(STATIC_ASSETS)) - .then(() => caches.open(CACHE_API).then(c => - fetch('/api/training/exercises').then(r => { if (r.ok) c.put('/api/training/exercises', r); }).catch(() => {}) - )) + .then(() => { + // Prioritäts-Seiten nicht-blockierend im Hintergrund cachen + caches.open(CACHE_STATIC).then(cache => { + PRIORITY_PAGES.forEach(page => + fetch(page).then(r => { if (r.ok) cache.put(page, r.clone()); }).catch(() => {}) + ); + }); + // Training-Exercises vorwärmen + return caches.open(CACHE_API).then(c => + fetch('/api/training/exercises').then(r => { if (r.ok) c.put('/api/training/exercises', r); }).catch(() => {}) + ); + }) ); }); @@ -310,21 +318,7 @@ self.addEventListener('fetch', event => { return; } - // Prioritäts-Seiten: Stale-While-Revalidate — sofort aus Cache, im Hintergrund aktualisieren - if (PRIORITY_PAGES.includes(url.pathname)) { - event.respondWith( - caches.open(CACHE_STATIC).then(async cache => { - const cached = await cache.match(event.request, { ignoreSearch: true }); - const netFetch = fetch(event.request) - .then(res => { if (res.ok) cache.put(event.request, res.clone()); return res; }) - .catch(() => null); - return cached ?? (await netFetch) ?? new Response('', { status: 503 }); - }) - ); - return; - } - - // CSS, Core-JS + übrige Seiten-Module: Network-First — damit iOS nie veraltete Versionen cached + // CSS, Core-JS + Seiten-Module: Network-First mit ignoreSearch-Fallback für Offline if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/') || url.pathname.startsWith('/js/app.js') || url.pathname.startsWith('/js/ui.js') || url.pathname.startsWith('/js/api.js') || url.pathname.startsWith('/js/worlds.js')) { From 0f6b5afd6a8284f4bee70f1283eedb792a434550 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 15:56:25 +0200 Subject: [PATCH 043/113] =?UTF-8?q?Fix:=20Syntaxfehler=20in=20invoices.py?= =?UTF-8?q?=20=E2=80=94=20fehlende=20schlie=C3=9Fende=20Klammer=20bei=20?= =?UTF-8?q?=5Fs()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/invoices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 784b5e9..dc54a90 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -252,7 +252,7 @@ def _generate_pdf(invoice, items) -> bytes: pdf.set_x(20) pdf.set_font("Helvetica", "I", 8.5) pdf.set_text_color(*GY) - pdf.multi_cell(W, 5, _s("Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet.") + pdf.multi_cell(W, 5, _s("Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet.")) # ── Zahlungsinfo-Box ────────────────────────────────────────── pdf.ln(5) From e714580d7715234420627679acb97765b02a7105 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 16:06:08 +0200 Subject: [PATCH 044/113] Feat: Cashflow auf paid_amount, Differenz-Badge, Kulanz-Abschreibung im Bezahlt-Modal (SW by-v984) --- backend/main.py | 2 +- backend/routes/invoices.py | 24 ++++++++++---- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 54 +++++++++++++++++++++++++++++--- backend/static/sw.js | 2 +- 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/backend/main.py b/backend/main.py index da0e0ae..f41be0b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "983" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "984" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index dc54a90..53562f0 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -38,6 +38,7 @@ class InvoiceCreate(BaseModel): class PayBody(BaseModel): paid_at: str paid_amount: float + notes: Optional[str] = None class CancelBody(BaseModel): @@ -373,7 +374,9 @@ def get_cashflow(admin=Depends(require_admin)): with db() as conn: monthly = conn.execute(""" SELECT substr(created_at, 1, 7) AS month, - SUM(amount_gross) AS revenue, + SUM(CASE WHEN status='paid' + THEN COALESCE(paid_amount, amount_gross) + ELSE amount_gross END) AS revenue, COUNT(*) AS count FROM invoices WHERE status IN ('sent', 'paid') @@ -383,7 +386,10 @@ def get_cashflow(admin=Depends(require_admin)): year = datetime.now().year total_year = conn.execute( - "SELECT COALESCE(SUM(amount_gross),0) FROM invoices WHERE status IN ('sent','paid') AND created_at LIKE ?", + """SELECT COALESCE(SUM(CASE WHEN status='paid' + THEN COALESCE(paid_amount, amount_gross) + ELSE amount_gross END), 0) + FROM invoices WHERE status IN ('sent','paid') AND created_at LIKE ?""", (f"{year}%",) ).fetchone()[0] @@ -705,10 +711,16 @@ def pay_invoice(invoice_id: int, data: PayBody, admin=Depends(require_admin)): raise HTTPException(404, "Rechnung nicht gefunden.") if row["status"] == "cancelled": raise HTTPException(400, "Stornierte Rechnung kann nicht als bezahlt markiert werden.") - conn.execute( - "UPDATE invoices SET status='paid', paid_at=?, paid_amount=? WHERE id=?", - (data.paid_at, data.paid_amount, invoice_id) - ) + if data.notes: + conn.execute( + "UPDATE invoices SET status='paid', paid_at=?, paid_amount=?, notes=? WHERE id=?", + (data.paid_at, data.paid_amount, data.notes, invoice_id) + ) + else: + conn.execute( + "UPDATE invoices SET status='paid', paid_at=?, paid_amount=? WHERE id=?", + (data.paid_at, data.paid_amount, invoice_id) + ) row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() return _row_to_dict(row) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index b33aa1b..a4a7573 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '983'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '984'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 81dc438..c3ae84f 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3797,6 +3797,14 @@ window.Page_admin = (() => {
- + `; diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js index 2a3988b..150fddc 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -583,21 +583,25 @@ window.Page_poison = (() => { // ---------------------------------------------------------- function _showPoisonThanks(isQueued) { const offlineNote = isQueued - ? `

- 📡 Wird synchronisiert sobald du wieder online bist. + ? `

+ + Wird synchronisiert sobald du wieder online bist.

` : ''; UI.modal.open({ - title: '✅ Danke für deine Meldung!', + title: 'Danke für deine Meldung!', body: `
-
🚨
+
+ +

Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung.

- Vielen Dank, dass du die Community schützt! 🐾 + margin:var(--space-2) 0 0;line-height:1.5;display:flex;align-items:center;justify-content:center;gap:var(--space-2)"> + + Vielen Dank, dass du die Community schützt!

${offlineNote}
diff --git a/backend/static/sw.js b/backend/static/sw.js index bb12985..599618d 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v994'; +const CACHE_VERSION = 'by-v995'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 1d67d6307e234acf2e1985dbdf86c850aeeb28e4 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 18:19:55 +0200 Subject: [PATCH 061/113] =?UTF-8?q?Fix:=20Routen-Aufzeichnung=20offline=20?= =?UTF-8?q?=E2=80=94=20Dim-Screen=20+=20WakeLock=20+=20GPS=20funktionieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _startRecInOvl() crashte bei null _recMap auf L.polyline().addTo(_recMap) → WakeLock, watchPosition, _resetRecInactTimer() wurden nie erreicht → Dim-Screen wurde nie aktiviert, GPS-Track lief nicht. - L.polyline nur erstellen wenn _recMap && window.L vorhanden - watchPosition-Callback: _recPolyline?.addLatLng, _recLocMarker?.setLatLng, _recMap?.setView alle mit Optional Chaining gesichert - SW by-v996, APP_VER 996 --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/routes.js | 10 ++++++---- backend/static/sw.js | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/main.py b/backend/main.py index 2a4beb4..624a637 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "995" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "996" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 91ccd34..5e5a60e 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '995'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '996'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 7213da3..7ccce1a 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -821,7 +821,9 @@ window.Page_routes = (() => { btn.addEventListener('pointercancel', cancelHold); document.getElementById('rk-rec-stats-bar').style.display = ''; - _recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap); + if (_recMap && window.L) { + _recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap); + } await _recAcquireWakeLock(); document.addEventListener('visibilitychange', _recOnVisibility); @@ -836,9 +838,9 @@ window.Page_routes = (() => { _recDistKm += d; } _recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) }); - _recPolyline.addLatLng([lat, lon]); - _recLocMarker.setLatLng([lat, lon]); - if (_recTrack.length === 1) _recMap.setView([lat, lon], 16); + _recPolyline?.addLatLng([lat, lon]); + _recLocMarker?.setLatLng([lat, lon]); + if (_recTrack.length === 1) _recMap?.setView([lat, lon], 16); _updateRecStats(); }, () => {}, { enableHighAccuracy: true, maximumAge: 2000 }); diff --git a/backend/static/sw.js b/backend/static/sw.js index 599618d..99eb6dc 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v995'; +const CACHE_VERSION = 'by-v996'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 902e6b8602537900a63fff53e8bb2c9434f1e7f5 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 18:28:50 +0200 Subject: [PATCH 062/113] Feature: Einmaliger Offline-Hinweis 'App im Vordergrund lassen' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim ersten offline-Event pro Session erscheint ein blauer Info-Toast (8s): 'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.' sessionStorage-Guard verhindert Wiederholung. SW by-v997, APP_VER 997 --- backend/main.py | 2 +- backend/static/index.html | 10 ++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/routes.js | 1 + backend/static/sw.js | 2 +- 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/main.py b/backend/main.py index 624a637..57280f3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "996" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "997" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index c829570..c6653b5 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -625,6 +625,16 @@ } window.addEventListener('offline', function() { _updateBanner(); + // Einmaliger Hinweis pro Session: App im Vordergrund lassen + if (!sessionStorage.getItem('by_offline_hint_shown')) { + sessionStorage.setItem('by_offline_hint_shown', '1'); + setTimeout(function() { + window.UI?.toast?.info( + 'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.', + 8000 + ); + }, 800); + } // Queue-Count abfragen if (navigator.serviceWorker) { navigator.serviceWorker.ready.then(function(reg) { diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 5e5a60e..d4a02cb 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '996'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '997'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 7ccce1a..b09e4fb 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -780,6 +780,7 @@ window.Page_routes = (() => { setTimeout(() => banner.remove(), 9000); } + const ctrl = document.getElementById('rk-rec-ctrl'); ctrl.innerHTML = `
${_fmtEur(inv.amount_gross)} + ${inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01 + ? `
+ erhalten: ${_fmtEur(inv.paid_amount)} + ${inv.paid_amount < inv.amount_gross + ? `-${_fmtEur(inv.amount_gross - inv.paid_amount)}` + : ''} +
` + : ''}
${_statusBadge(inv.status)} @@ -4091,10 +4099,13 @@ window.Page_admin = (() => {
- - Eingegangener Betrag (€) * +
+ `, footer: ` @@ -4103,18 +4114,51 @@ window.Page_admin = (() => { `, }); + // Differenz live anzeigen + const amtEl = document.getElementById(`${id}-amt`); + const diffEl = document.getElementById(`${id}-diff`); + const _checkDiff = () => { + const entered = parseFloat(amtEl?.value) || 0; + const diff = defaultAmount - entered; + if (Math.abs(diff) < 0.01) { diffEl.style.display = 'none'; return; } + diffEl.style.display = 'block'; + if (diff > 0) { + diffEl.innerHTML = `Differenz: -${diff.toFixed(2)} € weniger als fakturiert.
+ `; + } else { + diffEl.innerHTML = `Überzahlung: +${(-diff).toFixed(2)} € mehr eingegangen.`; + diffEl.style.background = '#f0fff8'; + diffEl.style.borderColor = '#34d399'; + diffEl.style.color = '#065f46'; + } + }; + amtEl?.addEventListener('input', _checkDiff); + document.getElementById(id)?.addEventListener('submit', async e => { e.preventDefault(); - const fd = new FormData(e.target); + const fd = new FormData(e.target); + const paidAmount = parseFloat(fd.get('paid_amount')); + const diff = defaultAmount - paidAmount; + const kulanz = diff > 0.01 && document.getElementById(`${id}-kulanz`)?.checked; + const submitBtn = document.querySelector(`button[form="${id}"]`); if (submitBtn) submitBtn.disabled = true; try { + const kulanzNote = kulanz + ? `Forderungsverlust/Kulanz: ${diff.toFixed(2)} EUR nicht eingegangen (${fd.get('paid_at')}). Als Kulanz abgeschrieben.` + : null; await API.post(`/admin/invoices/${invoiceId}/pay`, { paid_at: fd.get('paid_at'), - paid_amount: parseFloat(fd.get('paid_amount')), + paid_amount: paidAmount, + ...(kulanzNote ? { notes: kulanzNote } : {}), }); UI.modal.close(); - UI.toast.success('Rechnung als bezahlt markiert.'); + UI.toast.success(kulanz + ? `Bezahlt (${paidAmount.toFixed(2)} €) · ${diff.toFixed(2)} € als Kulanz notiert.` + : 'Rechnung als bezahlt markiert.'); reload(); } catch (err) { UI.toast.error(err.message || 'Fehler.'); diff --git a/backend/static/sw.js b/backend/static/sw.js index 6fb938c..4b9fc1f 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v983'; +const CACHE_VERSION = 'by-v984'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From ee280fdaaeb115c0d48bc7b769c552e6b2abb24c Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 16:10:53 +0200 Subject: [PATCH 045/113] =?UTF-8?q?Feat:=20Automatische=20Zahlungsmahnung?= =?UTF-8?q?=20(Tag=2021)=20+=20fristlose=20K=C3=BCndigung=20(Tag=2035)=20p?= =?UTF-8?q?er=20Scheduler=20(=C2=A7314=20BGB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/scheduler.py | 119 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/backend/scheduler.py b/backend/scheduler.py index 91e8163..aee77b7 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -195,6 +195,13 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + _scheduler.add_job( + _job_invoice_reminder, + CronTrigger(hour=8, minute=30), # täglich 08:30 Uhr + id="invoice_reminder", + replace_existing=True, + misfire_grace_time=3600, + ) _scheduler.start() logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00, Abo-Check 03:00. OSM-Cache: on-demand (kein Prewarm).") @@ -367,6 +374,118 @@ async def _remind_renewal_invoice(user: dict, expires: date, tier_label: str): logger.info(f"7-Tage-Erinnerung an Admin für {user['email']}: {draft['invoice_number']}") +async def _job_invoice_reminder(): + """ + Unbezahlte Rechnungen (status='sent'): + - Nach 21 Tagen: Zahlungsmahnung mit 14-Tage-Frist (§286/314 BGB) + - Nach 35 Tagen (21+14): Fristlose Abo-Kündigung + """ + from database import db as _db + from mailer import send_email, email_html + from routes.invoices import _next_invoice_number + import html as _html + import os + + APP_URL = os.getenv("APP_URL", "https://banyaro.app") + IBAN = os.getenv("RECHNUNG_IBAN", "") + ADMIN_MAIL = os.getenv("ADMIN_EMAIL", "") + + today = datetime.now(_TZ).date() + + with db() as conn: + open_invoices = conn.execute( + """SELECT i.*, u.name AS user_name, u.subscription_tier, u.id AS uid + FROM invoices i + LEFT JOIN users u ON u.id = i.user_id + WHERE i.status = 'sent' + AND i.sent_at IS NOT NULL""" + ).fetchall() + + for inv in open_invoices: + try: + sent_date = datetime.fromisoformat(inv["sent_at"].replace("Z", "+00:00")).date() + days_open = (today - sent_date).days + rg = inv["invoice_number"] + name = inv["recipient_name"] + email = inv["recipient_email"] + amount = inv["amount_gross"] + frist = (today + timedelta(days=14)).strftime("%d.%m.%Y") + + # ── 21 Tage: Zahlungsmahnung mit 14-Tage-Frist ─────────── + if days_open == 21: + iban_line = f"

IBAN: {IBAN} · Verwendungszweck: {rg}

" if IBAN else "" + body = f""" +

Hallo {_html.escape(name)},

+

+ unsere Rechnung {rg} vom {datetime.fromisoformat(inv['created_at'][:10]).strftime('%d.%m.%Y')} + über {amount:.2f} EUR ist leider noch offen. +

+

+ Bitte überweisen Sie den Betrag bis zum {frist}. + {iban_line} +

+

+ Sollte die Zahlung bis zu diesem Datum nicht eingehen, sind wir leider gezwungen, + Ihr Abonnement fristlos zu kündigen (§ 314 BGB). +

""" + html = email_html(body, cta_url=APP_URL, cta_label="Ban Yaro öffnen") + await send_email( + email, + f"Zahlungserinnerung: Rechnung {rg} — Ban Yaro", + html, + f"Hallo {name},\n\nRechnung {rg} über {amount:.2f} EUR ist noch offen.\n" + f"Bitte bis {frist} überweisen. Andernfalls kündigen wir fristlos.\n" + + (f"IBAN: {IBAN}, Verwendungszweck: {rg}\n" if IBAN else "") + ) + logger.info(f"Zahlungsmahnung gesendet: {rg} an {email} (21 Tage offen)") + + # ── 35 Tage: Fristlose Kündigung ───────────────────────── + elif days_open == 35: + # Abo kündigen wenn Nutzer zugeordnet und aktives Abo + if inv["uid"] and inv["subscription_tier"] not in (None, "standard", "standard_test"): + with db() as conn2: + conn2.execute( + """UPDATE users SET subscription_tier='standard', + subscription_expires_at=NULL, + subscription_cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') + WHERE id=?""", + (inv["uid"],) + ) + logger.info(f"Fristlose Kündigung: user {inv['uid']} wegen unbezahlter Rechnung {rg}") + + body = f""" +

Hallo {_html.escape(name)},

+

+ da die Zahlung für Rechnung {rg} ({amount:.2f} EUR) + trotz unserer Zahlungserinnerung nicht eingegangen ist, + haben wir Ihr Abonnement gemäß § 314 BGB fristlos gekündigt. +

+

+ Ihre Daten bleiben vollständig erhalten. Sie können jederzeit ein neues Abonnement abschließen. +

+

+ Bei Rückfragen antworten Sie einfach auf diese E-Mail. +

""" + html = email_html(body, cta_url=APP_URL, cta_label="Ban Yaro öffnen") + await send_email( + email, + f"Ihr Ban Yaro Abonnement wurde gekündigt — Rechnung {rg}", + html, + f"Hallo {name},\n\nIhr Abo wurde wegen unbezahlter Rechnung {rg} fristlos gekündigt.\n" + f"Ihre Daten sind erhalten. Neue Buchung jederzeit möglich.\n" + ) + if ADMIN_MAIL: + await send_email( + ADMIN_MAIL, + f"Fristlose Kündigung: {name} — {rg} ({amount:.2f} EUR unbezahlt)", + email_html(f"

Abo von {_html.escape(name)} ({email}) wurde automatisch fristlos gekündigt (§314 BGB). Rechnung {rg} seit 35 Tagen offen.

"), + f"Abo {name} gekündigt wegen unbezahlter Rechnung {rg}." + ) + + except Exception as e: + logger.warning(f"Invoice-Reminder Fehler für {inv.get('invoice_number','?')}: {e}") + + async def _job_subscription_check(): """Abgelaufene Abos auf Standard setzen; Warnmails 30 und 7 Tage vorher.""" from database import db as _db From d20e63496cace49fa65d68b95483a614063bbfda Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 16:19:46 +0200 Subject: [PATCH 046/113] Feat: AGB-Link im Footer (Welt-Welt) + AGB-Checkbox im Upgrade-Modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - worlds.js: 'Datenschutz · AGB' in der Welt-Welt-Fußzeile - settings.js: AGB-Checkbox über Widerrufs-Checkbox; beide müssen gecheckt sein bevor 'Anfrage senden' aktiv wird --- backend/static/js/pages/settings.js | 24 ++++++++++++++++++++---- backend/static/js/worlds.js | 2 ++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 2cb9b49..d46459d 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -313,6 +313,18 @@ window.Page_settings = (() => { font-size:var(--text-xs);color:#c05000;line-height:1.6;margin-top:var(--space-2)"> 💡 Tipp: Trag deine Rechnungsadresse im Profil ein — dann können wir die Rechnung vollständig ausstellen. ` : ''} +
+ +
`; From d7f7a7e454d54bc8e98f56c615ef3f6e7c8f6e86 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 16:21:04 +0200 Subject: [PATCH 047/113] Neu: AGB-Seite + Impressum/Datenschutz aktualisiert (SW by-v985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Seite agb.js mit 11 Abschnitten (Laufzeit, Zahlung, Widerruf etc.) - Datenschutz: 'Abonnement & Kündigung' → 'Zahlungsdaten' (DSGVO-Fokus), DDG-Hinweis ergänzt - Impressum: ODR-Link entfernt (EU-Plattform eingestellt 2025), Telefon-Pflichthinweis nach §5 DDG, Stand Mai 2026 - AGB-Link in alle Footer (index.html, landing.html, zuechter.html, welcome.js) - page-section #page-agb in index.html, Route 'agb' in app.js ROUTES --- backend/static/index.html | 5 + backend/static/js/app.js | 3 +- backend/static/js/pages/agb.js | 150 +++++++++++++++++++++++++ backend/static/js/pages/datenschutz.js | 38 +++---- backend/static/js/pages/impressum.js | 11 +- backend/static/js/pages/welcome.js | 2 + backend/static/landing.html | 1 + backend/static/sw.js | 2 +- backend/static/zuechter.html | 1 + 9 files changed, 181 insertions(+), 32 deletions(-) create mode 100644 backend/static/js/pages/agb.js diff --git a/backend/static/index.html b/backend/static/index.html index 4430f06..c829570 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -296,6 +296,7 @@
Impressum Datenschutz + AGB
+
+
+
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a4a7573..7f2f4f3 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '984'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '985'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen @@ -64,6 +64,7 @@ const App = (() => { moderation: { title: 'Moderation', module: null, requiresAuth: true }, impressum: { title: 'Impressum', module: null }, datenschutz: { title: 'Datenschutz', module: null }, + agb: { title: 'AGB', module: null }, widget: { title: 'Widget', module: null, requiresAuth: true }, notifications: { title: 'Aktuelles', module: null, requiresAuth: true }, breeder: { title: 'Züchter-Profil', module: null }, diff --git a/backend/static/js/pages/agb.js b/backend/static/js/pages/agb.js new file mode 100644 index 0000000..f3dae3f --- /dev/null +++ b/backend/static/js/pages/agb.js @@ -0,0 +1,150 @@ +/* ============================================================ + BAN YARO — Allgemeine Geschäftsbedingungen + ============================================================ */ + +window.Page_agb = (() => { + + const S = { + h2: `font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-primary);margin:0 0 var(--space-2)`, + p: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0`, + ul: `font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:var(--space-2) 0 0;padding-left:var(--space-5)`, + a: `color:var(--c-primary)`, + }; + + function sec(title, body) { + return ` +
+

${title}

+ ${body} +
`; + } + + function init(container) { + container.innerHTML = ` +
+ +

Allgemeine Geschäftsbedingungen

+

Gültig ab Mai 2026

+ + ${sec('1. Geltungsbereich', ` +

+ Diese AGB gelten für die Nutzung der Plattform Ban Yaro + (banyaro.app), betrieben von:

+ René Degelmann
+ Ringstr. 26, 85560 Ebersberg
+ E-Mail: hallo@banyaro.app +

+

+ Sie gelten ausschließlich für kostenpflichtige Abonnements. Die kostenlose Nutzung + der App setzt lediglich die Registrierung voraus. +

`)} + + ${sec('2. Leistungen', ` +

Ban Yaro bietet folgende kostenpflichtige Abonnements an:

+
    +
  • + Ban Yaro Pro — 29 EUR/Jahr: Erweiterte App-Funktionen für mehrere + Hunde, KI-Features, zusätzliche Karten-Layer, Chat und Playdate-Funktion sowie + alle weiteren Pro-Funktionen laut aktuellem Funktionsumfang. +
  • +
  • + Ban Yaro Züchter — 49 EUR/Jahr: Alle Pro-Funktionen plus + Zuchtkartei, Stammbaum, Wurfverwaltung und Züchterprofil. +
  • +
+

+ Änderungen am Funktionsumfang werden vorab per E-Mail angekündigt. Wesentliche + Leistungsminderungen berechtigen zur außerordentlichen Kündigung. +

`)} + + ${sec('3. Preise und Zahlung', ` +

+ Der Jahresbeitrag ist bei Vertragsschluss für die gesamte Laufzeit im Voraus fällig. + Die Zahlung erfolgt per Überweisung — IBAN und Verwendungszweck stehen auf der + Rechnung, die per E-Mail zugestellt wird. Der Betrag ist innerhalb von + 14 Tagen nach Rechnungsstellung zu überweisen. +

+

+ Bei Zahlungsverzug erhalten Sie zunächst eine Zahlungserinnerung. Bleibt der Betrag + danach weiterhin ausstehend, behalten wir uns die fristlose Kündigung des Vertrags + gemäß § 314 BGB vor. +

`)} + + ${sec('4. Vertragslaufzeit und Kündigung', ` +

+ Die Erstlaufzeit beträgt 12 Monate ab dem Tag der Freischaltung. + Nach Ablauf verlängert sich der Vertrag auf unbestimmte Zeit — kündbar jederzeit + mit einer Frist von einem Monat zum Monatsende (§ 309 Nr. 9 BGB). +

+

+ Die Kündigung ist jederzeit bequem in den App-Einstellungen möglich. + Der Zugang bleibt bis zum Ende der bereits bezahlten Laufzeit vollständig aktiv. +

`)} + + ${sec('5. Kein Erstattungsanspruch', ` +

+ Bei vorzeitiger Kündigung durch den Nutzer erfolgt keine anteilige Rückerstattung + des Jahresbeitrags. Der Zugang bleibt bis zum Ende der Laufzeit vollständig nutzbar — + du verlierst also nichts, was du bereits bezahlt hast. +

`)} + + ${sec('6. Widerrufsrecht', ` +

+ Da die Nutzung sofort nach der Freischaltung beginnt und du dem beim Kauf + ausdrücklich zustimmst, erlischt das 14-tägige Widerrufsrecht gemäß + § 356 Abs. 4 BGB mit Beginn der Nutzung. Die Zustimmung erfolgt aktiv + durch eine Checkbox beim Kaufabschluss. +

`)} + + ${sec('7. Fristlose Kündigung durch den Anbieter', ` +

+ Wir sind berechtigt, den Vertrag aus wichtigem Grund fristlos zu kündigen + (§ 314 BGB). Ein wichtiger Grund liegt insbesondere vor, wenn nach einer + Zahlungserinnerung der offene Betrag weiterhin nicht beglichen wird. + In diesem Fall endet der Zugang mit Wirkung der Kündigung. +

`)} + + ${sec('8. Verfügbarkeit', ` +

+ Wir streben eine hohe Verfügbarkeit von Ban Yaro an und arbeiten kontinuierlich + daran, die App stabil zu halten. Eine Garantie für ununterbrochene Verfügbarkeit + können wir jedoch nicht übernehmen. Geplante Wartungsarbeiten werden nach + Möglichkeit vorab in der App angekündigt. +

`)} + + ${sec('9. Änderungen dieser AGB', ` +

+ Änderungen der AGB werden per E-Mail und in der App angekündigt — + mindestens 4 Wochen vor Inkrafttreten. Widersprichst du den Änderungen nicht + innerhalb dieser Frist, gelten sie als angenommen. Dein Widerspruchsrecht und + das Recht zur außerordentlichen Kündigung bleiben unberührt. +

`)} + + ${sec('10. Anwendbares Recht', ` +

+ Es gilt ausschließlich deutsches Recht. Als Verbraucher hast du + deinen allgemeinen Gerichtsstand. Die EU-Plattform zur Online-Streitbeilegung + (ec.europa.eu/consumers/odr) wurde eingestellt. Wir nehmen nicht an alternativen + Streitbeilegungsverfahren teil (§ 36 VSBG). +

`)} + + ${sec('11. Kontakt', ` +

+ René Degelmann
+ Ringstr. 26, 85560 Ebersberg
+ E-Mail: hallo@banyaro.app +

`)} + +

+ Stand: Mai 2026 · Version 1 +

+ +
+ `; + } + + function refresh() {} + + return { init, refresh }; +})(); diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js index 368010c..4a47a60 100644 --- a/backend/static/js/pages/datenschutz.js +++ b/backend/static/js/pages/datenschutz.js @@ -70,6 +70,9 @@ window.Page_datenschutz = (() => { Push-Benachrichtigungen. Einwilligungen können jederzeit mit Wirkung für die Zukunft widerrufen werden (Art. 7 Abs. 3 DSGVO) — einfach die entsprechende Funktion in den Einstellungen deaktivieren oder die Browser-Freigabe entziehen. +

+

+ Impressum und rechtliche Grundlage nach § 5 DDG (Digitale-Dienste-Gesetz).

`)} ${sec('Datenweitergabe', ` @@ -238,34 +241,19 @@ window.Page_datenschutz = (() => { style="${S.a}">www.lda.bayern.de

`)} - ${sec('Abonnement & Kündigung', ` + ${sec('Zahlungsdaten', `

- Ban Yaro Pro und das Züchter-Paket sind Jahresabonnements mit einer Laufzeit von - 12 Monaten ab Freischaltung. + Wenn du ein kostenpflichtiges Abonnement abschließt, verarbeiten wir folgende Daten: + Name, E-Mail-Adresse, Rechnungsadresse und den Zahlungseingang. Rechtsgrundlage ist + Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). Rechnungsdaten werden gemäß + § 147 AO 10 Jahre aufbewahrt. Rechnungen werden per E-Mail mit + TLS-Verschlüsselung zugestellt.

- Laufzeit & Verlängerung: Das Abonnement läuft 12 Monate ab dem - Tag der Freischaltung. Nach Ablauf verlängert es sich auf unbestimmte Zeit mit einer - Kündigungsfrist von einem Monat zum Monatsende, sofern nicht vorher gekündigt wird - (§ 309 Nr. 9 BGB). -

-

- Zahlung: Der Jahresbeitrag (29 EUR für Pro, 49 EUR für - Züchter) wird einmalig für die gesamte Laufzeit im Voraus fällig. -

-

- Kündigung: Die Kündigung kann jederzeit in den Einstellungen der App - erfolgen. Der Zugang bleibt bis zum Ende der bezahlten Laufzeit erhalten. -

-

- Erstattung: Bei vorzeitiger Kündigung durch den Nutzer erfolgt keine - anteilige Rückerstattung des Jahresbeitrags. Der Zugang bleibt bis zum Ende der - bezahlten Laufzeit vollständig bestehen. -

-

- Widerrufsrecht: Da die Nutzung sofort nach Freischaltung beginnt und - der Nutzer dem ausdrücklich zustimmt, erlischt das 14-tägige Widerrufsrecht gemäß - § 356 Abs. 4 BGB mit Beginn der Nutzung. + Deine Zahlungsdaten (IBAN) werden nur für die Zuordnung des Zahlungseingangs intern + verwendet und nicht an Dritte weitergegeben. Die vertraglichen Bedingungen (Laufzeit, + Kündigung, Erstattung) findest du in unseren + AGB.

`)} ${sec('Speicherdauer', ` diff --git a/backend/static/js/pages/impressum.js b/backend/static/js/pages/impressum.js index ffccb44..0d7f102 100644 --- a/backend/static/js/pages/impressum.js +++ b/backend/static/js/pages/impressum.js @@ -24,11 +24,15 @@ window.Page_impressum = (() => {

Kontakt

+

E-Mail: hallo@banyaro.app
Kontaktformular: Nachricht senden + style="color:var(--c-primary)">Nachricht senden
+ + Telefonnummer folgt (Pflichtangabe nach § 5 DDG) +

@@ -46,9 +50,6 @@ window.Page_impressum = (() => {

Streitschlichtung

- Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: - https://ec.europa.eu/consumers/odr.
Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen (§ 36 VSBG).

@@ -67,7 +68,7 @@ window.Page_impressum = (() => {

- Stand: April 2026 + Stand: Mai 2026

diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js index 9e60fb0..256f499 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -167,6 +167,8 @@ window.Page_welcome = (() => { Impressum  ·  Datenschutz +  ·  + AGB

`; diff --git a/backend/static/landing.html b/backend/static/landing.html index 39d1399..1c25492 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -1533,6 +1533,7 @@ diff --git a/backend/static/sw.js b/backend/static/sw.js index 4b9fc1f..27d268b 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v984'; +const CACHE_VERSION = 'by-v985'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache diff --git a/backend/static/zuechter.html b/backend/static/zuechter.html index e7e6b39..9b30e38 100644 --- a/backend/static/zuechter.html +++ b/backend/static/zuechter.html @@ -592,6 +592,7 @@ From 738e354b0a2854106af4b05700a3a5a5f4cbd014 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 16:24:18 +0200 Subject: [PATCH 048/113] =?UTF-8?q?Fix:=20APP=5FVER=20984=E2=86=92985=20in?= =?UTF-8?q?=20main.py=20(Dauerschleife)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index f41be0b..099f0b7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "984" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "985" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): From 0f09f5a8ddc9d7aa9795ca5403ea4915624389bc Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 16:41:19 +0200 Subject: [PATCH 049/113] =?UTF-8?q?Rechtliche=20Seiten=20=C3=BCberarbeitet?= =?UTF-8?q?:=20Impressum,=20Datenschutz,=20AGB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Impressum: Telefon-Placeholder entfernt, Kontakt auf E-Mail reduziert, UGC-Haftungsklausel auf §§ 7 ff. DDG-Basis präzisiert. Datenschutz: Neue Abschnitte Hosting & Infrastruktur (Brevo, Umami), Technische Speicherung (TTDSG), Mindestalter, Moderation & Community; KI-Abschnitt um US-Behörden-Restrisiko-Hinweis ergänzt; BayLDA-Adresse korrigiert (Promenade 18) und E-Mail ergänzt; Version 3. AGB: Neue Abschnitte Mindestalter (§ 2), Nutzungsregeln/Community (§ 4), Nutzerinhalte/Lizenzen (§ 5), KI-Haftung (§ 11); Widerrufsrecht auf aktive Protokollierung präzisiert; Kündigungsbutton-Hinweis nach § 312k BGB; Erstattungsausschluss um gesetzliche Ansprüche ergänzt; Abschnitte neu durchnummeriert (1–15), Version 2. --- backend/static/js/pages/agb.js | 77 ++++++++++++++++++++------ backend/static/js/pages/datenschutz.js | 55 +++++++++++++++++- backend/static/js/pages/impressum.js | 13 ++--- 3 files changed, 119 insertions(+), 26 deletions(-) diff --git a/backend/static/js/pages/agb.js b/backend/static/js/pages/agb.js index f3dae3f..9a1c2e2 100644 --- a/backend/static/js/pages/agb.js +++ b/backend/static/js/pages/agb.js @@ -40,7 +40,14 @@ window.Page_agb = (() => { der App setzt lediglich die Registrierung voraus.

`)} - ${sec('2. Leistungen', ` + ${sec('2. Mindestalter', ` +

+ Die Nutzung von Ban Yaro, insbesondere die Registrierung und der Abschluss eines + Abonnements, ist nur Personen ab 18 Jahren gestattet. Mit Abschluss des Vertrags + bestätigt der Nutzer, volljährig zu sein. +

`)} + + ${sec('3. Leistungen', `

Ban Yaro bietet folgende kostenpflichtige Abonnements an:

  • @@ -58,7 +65,32 @@ window.Page_agb = (() => { Leistungsminderungen berechtigen zur außerordentlichen Kündigung.

    `)} - ${sec('3. Preise und Zahlung', ` + ${sec('4. Nutzungsregeln / Community', ` +

    Die Nutzung der Plattform-Features (Forum, Chat, Fotos, Kommentare) unterliegt folgenden Regeln:

    +
      +
    • Keine rechtswidrigen, beleidigenden, diskriminierenden oder irreführenden Inhalte
    • +
    • Kein Spam, keine Werbung ohne Genehmigung, keine Fake-Accounts
    • +
    • Respektvoller Umgang mit anderen Nutzern
    • +
    • Keine Verletzung von Urheberrechten Dritter bei hochgeladenen Inhalten
    • +
    +

    + Bei Verstoß sind wir berechtigt, Inhalte zu entfernen und Accounts zu sperren oder + zu kündigen. Rechtswidrige Inhalte werden unverzüglich entfernt und ggf. Behörden + gemeldet. Meldungen können an + hallo@banyaro.app + gerichtet werden. +

    `)} + + ${sec('5. Nutzerinhalte und Lizenzen', ` +

    + Durch das Hochladen von Inhalten (Fotos, Texte, Beiträge) räumt der Nutzer Ban Yaro + eine nicht-exklusive, kostenlose, weltweite Lizenz ein, diese Inhalte im Rahmen der + Plattform zu speichern, anzuzeigen und technisch zu verarbeiten. Diese Lizenz erlischt + mit Löschung des Inhalts oder Löschung des Accounts. Urheberrechte und sonstige + Rechte der Nutzer an ihren Inhalten bleiben unberührt. +

    `)} + + ${sec('6. Preise und Zahlung', `

    Der Jahresbeitrag ist bei Vertragsschluss für die gesamte Laufzeit im Voraus fällig. Die Zahlung erfolgt per Überweisung — IBAN und Verwendungszweck stehen auf der @@ -71,33 +103,37 @@ window.Page_agb = (() => { gemäß § 314 BGB vor.

    `)} - ${sec('4. Vertragslaufzeit und Kündigung', ` + ${sec('7. Vertragslaufzeit und Kündigung', `

    Die Erstlaufzeit beträgt 12 Monate ab dem Tag der Freischaltung. Nach Ablauf verlängert sich der Vertrag auf unbestimmte Zeit — kündbar jederzeit mit einer Frist von einem Monat zum Monatsende (§ 309 Nr. 9 BGB).

    - Die Kündigung ist jederzeit bequem in den App-Einstellungen möglich. + Die Kündigung ist jederzeit in den App-Einstellungen unter + Einstellungen → Abonnement → Kündigen möglich (§ 312k BGB). + Eine Kündigungsbestätigung wird per E-Mail zugesandt. Der Zugang bleibt bis zum Ende der bereits bezahlten Laufzeit vollständig aktiv.

    `)} - ${sec('5. Kein Erstattungsanspruch', ` + ${sec('8. Kein Erstattungsanspruch', `

    Bei vorzeitiger Kündigung durch den Nutzer erfolgt keine anteilige Rückerstattung des Jahresbeitrags. Der Zugang bleibt bis zum Ende der Laufzeit vollständig nutzbar — du verlierst also nichts, was du bereits bezahlt hast. + Gesetzliche Ansprüche bei vertragswidrigen Leistungen bleiben unberührt.

    `)} - ${sec('6. Widerrufsrecht', ` + ${sec('9. Widerrufsrecht', `

    - Da die Nutzung sofort nach der Freischaltung beginnt und du dem beim Kauf - ausdrücklich zustimmst, erlischt das 14-tägige Widerrufsrecht gemäß - § 356 Abs. 4 BGB mit Beginn der Nutzung. Die Zustimmung erfolgt aktiv - durch eine Checkbox beim Kaufabschluss. + Da die Nutzung unmittelbar nach Freischaltung beginnt und du beim Kauf ausdrücklich + zustimmst, dass die Vertragserfüllung vor Ablauf der Widerrufsfrist beginnt, erlischt + dein 14-tägiges Widerrufsrecht mit Beginn der Nutzung (§ 356 Abs. 4 BGB). Dir ist + bekannt, dass du durch diese Zustimmung dein Widerrufsrecht verlierst. Die Zustimmung + wird beim Kauf aktiv protokolliert.

    `)} - ${sec('7. Fristlose Kündigung durch den Anbieter', ` + ${sec('10. Fristlose Kündigung durch den Anbieter', `

    Wir sind berechtigt, den Vertrag aus wichtigem Grund fristlos zu kündigen (§ 314 BGB). Ein wichtiger Grund liegt insbesondere vor, wenn nach einer @@ -105,7 +141,16 @@ window.Page_agb = (() => { In diesem Fall endet der Zugang mit Wirkung der Kündigung.

    `)} - ${sec('8. Verfügbarkeit', ` + ${sec('11. KI-Funktionen / Haftung für KI-Inhalte', ` +

    + KI-generierte Inhalte (Trainer-Empfehlungen, Gesundheitshinweise, Züchter-Analysen) + können fehlerhaft oder unvollständig sein. Sie dienen ausschließlich der allgemeinen + Information und ersetzen keine tierärztliche, veterinärmedizinische oder fachliche + Beratung. Ban Yaro haftet nicht für Schäden, die aus der Nutzung KI-generierter + Inhalte entstehen. +

    `)} + + ${sec('12. Verfügbarkeit', `

    Wir streben eine hohe Verfügbarkeit von Ban Yaro an und arbeiten kontinuierlich daran, die App stabil zu halten. Eine Garantie für ununterbrochene Verfügbarkeit @@ -113,7 +158,7 @@ window.Page_agb = (() => { Möglichkeit vorab in der App angekündigt.

    `)} - ${sec('9. Änderungen dieser AGB', ` + ${sec('13. Änderungen dieser AGB', `

    Änderungen der AGB werden per E-Mail und in der App angekündigt — mindestens 4 Wochen vor Inkrafttreten. Widersprichst du den Änderungen nicht @@ -121,7 +166,7 @@ window.Page_agb = (() => { das Recht zur außerordentlichen Kündigung bleiben unberührt.

    `)} - ${sec('10. Anwendbares Recht', ` + ${sec('14. Anwendbares Recht', `

    Es gilt ausschließlich deutsches Recht. Als Verbraucher hast du deinen allgemeinen Gerichtsstand. Die EU-Plattform zur Online-Streitbeilegung @@ -129,7 +174,7 @@ window.Page_agb = (() => { Streitbeilegungsverfahren teil (§ 36 VSBG).

    `)} - ${sec('11. Kontakt', ` + ${sec('15. Kontakt', `

    René Degelmann
    Ringstr. 26, 85560 Ebersberg
    @@ -137,7 +182,7 @@ window.Page_agb = (() => {

    `)}

    - Stand: Mai 2026 · Version 1 + Stand: Mai 2026 · Version 2

    diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js index 4a47a60..893883f 100644 --- a/backend/static/js/pages/datenschutz.js +++ b/backend/static/js/pages/datenschutz.js @@ -32,6 +32,26 @@ window.Page_datenschutz = (() => { E-Mail: hallo@banyaro.app

    `)} + ${sec('Hosting & Infrastruktur', ` +

    + Die App wird auf einem eigenen Server (Synology DiskStation) in Deutschland betrieben. + Alle Daten werden ausschließlich auf diesem Server gespeichert und nicht an externe + Hoster übermittelt. +

    +

    + Für den E-Mail-Versand (Kontobestätigung, Benachrichtigungen, Rechnungen) nutzen wir + Brevo (Sendinblue SAS, 55 rue d'Amsterdam, 75008 Paris, Frankreich). + Brevo ist nach EU-Standardvertragsklauseln zertifiziert. Dabei werden E-Mail-Adresse + und Name übermittelt. Datenschutzinformationen: + brevo.com/de/legal/privacypolicy/. +

    +

    + Für anonymisierte Nutzungsstatistiken betreiben wir Umami Analytics + auf unserem eigenen Server. Es werden keine personenbezogenen Daten oder IP-Adressen + gespeichert. Kein Tracking über Sitzungen hinweg. +

    `)} + ${sec('Deine Daten gehören dir', `

    Ban Yaro ist eine private Community-App. Dein Tagebuch, deine @@ -95,6 +115,13 @@ window.Page_datenschutz = (() => { Du kannst Gespräche jederzeit selbst löschen.

    `)} + ${sec('Moderation & Community', ` +

    + Zur Sicherstellung der Plattformqualität und Einhaltung unserer Nutzungsregeln können + Moderatoren und automatische Systeme Inhalte prüfen. Rechtsgrundlage ist + Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an sicherer Plattform). +

    `)} + ${sec('KI-Funktionen', `

    Ban Yaro bietet KI-gestützte Funktionen (Trainingsempfehlungen, Terminvorschläge, @@ -130,6 +157,12 @@ window.Page_datenschutz = (() => { KI-Empfehlungen sind Vorschläge und ersetzen keine tierärztliche Beratung. Eine automatisierte Entscheidungsfindung mit rechtlicher Wirkung (Art. 22 DSGVO) findet nicht statt. +

    +

    + KI-Antworten können fehlerhaft oder unvollständig sein und dienen ausschließlich + allgemeinen Informationszwecken. Sie ersetzen keine tierärztliche oder fachliche + Beratung. Trotz EU-Standardvertragsklauseln besteht bei US-Anbietern ein Restrisiko, + dass US-Behörden auf übermittelte Daten zugreifen könnten.

    `)} ${sec('Wetterdaten & Kartendienste', ` @@ -182,6 +215,16 @@ window.Page_datenschutz = (() => { style="${S.a}">openrouteservice.org/privacy-policy

    `)} + ${sec('Technische Speicherung', ` +

    + Ban Yaro verwendet technisch notwendige Speichermechanismen für den Betrieb der App: + Session-Tokens und Authentifizierungsdaten werden im Local Storage des Browsers + gespeichert. Ein Service Worker speichert App-Inhalte lokal für die Offline-Nutzung + (Cache). Push-Benachrichtigungs-Token werden für die Zustellung von Hinweisen benötigt. + Diese Speicherung ist für die Kernfunktion der App erforderlich; eine Einwilligung ist + nach § 25 Abs. 2 TTDSG nicht erforderlich. Es werden keine Tracking-Cookies eingesetzt. +

    `)} + ${sec('Push-Benachrichtigungen', `

    Wenn du Push-Benachrichtigungen aktivierst, wird ein Abonnement-Token an den @@ -236,7 +279,9 @@ window.Page_datenschutz = (() => { Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde Beschwerde einzulegen:
    Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)
    - Promenade 27, 91522 Ansbach
    + Promenade 18, 91522 Ansbach
    + poststelle@lda.bayern.de · www.lda.bayern.de

    `)} @@ -264,8 +309,14 @@ window.Page_datenschutz = (() => { Server-Logs werden nach 30 Tagen rotiert.

    `)} + ${sec('Mindestalter', ` +

    + Die Nutzung von Ban Yaro ist nur Personen ab 18 Jahren gestattet. Durch die + Registrierung bestätigt der Nutzer, das 18. Lebensjahr vollendet zu haben. +

    `)} +

    - Stand: Mai 2026 · Version 2 + Stand: Mai 2026 · Version 3

    diff --git a/backend/static/js/pages/impressum.js b/backend/static/js/pages/impressum.js index 0d7f102..baacd1a 100644 --- a/backend/static/js/pages/impressum.js +++ b/backend/static/js/pages/impressum.js @@ -24,15 +24,10 @@ window.Page_impressum = (() => {

    Kontakt

    -

    E-Mail: hallo@banyaro.app
    - Kontaktformular: Nachricht senden
    - - Telefonnummer folgt (Pflichtangabe nach § 5 DDG) - + Wir antworten in der Regel innerhalb von 24 Stunden.

    @@ -62,8 +57,10 @@ window.Page_impressum = (() => { Die Inhalte dieser App wurden mit größtmöglicher Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte übernehmen wir keine Gewähr. Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte verantwortlich. - Für nutzergenerierte Inhalte (z. B. Forenbeiträge, Giftköder-Meldungen) übernehmen wir - keine Haftung; diese liegen in der Verantwortung der jeweiligen Nutzer. + Für nutzergenerierte Inhalte (Forenbeiträge, Fotos, Kommentare) sind ausschließlich + die jeweiligen Nutzer verantwortlich. Bei Bekanntwerden rechtswidriger Inhalte werden + diese im Rahmen der gesetzlichen Vorgaben (§§ 7 ff. DDG) geprüft und gegebenenfalls + unverzüglich entfernt.

    From 3fae57a0e229b733ac3206bc77a6c6c438f19ef4 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 16:46:37 +0200 Subject: [PATCH 050/113] Feat: Kontaktformular im Impressum + /api/contact Endpoint ohne Auth (SW by-v986) --- backend/main.py | 4 +- backend/routes/contact.py | 52 ++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/impressum.js | 104 ++++++++++++++++++++++++++- backend/static/sw.js | 2 +- 5 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 backend/routes/contact.py diff --git a/backend/main.py b/backend/main.py index 099f0b7..ae5e58c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -253,6 +253,7 @@ from routes.challenges import router as challenges_router from routes.gassi_zeiten import router as gassi_zeiten_router from routes.help import router as help_router from routes.feedback import router as feedback_router +from routes.contact import router as contact_router from routes.invoices import router as invoices_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) @@ -318,6 +319,7 @@ app.include_router(challenges_router, prefix="/api/challenges", ta app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"]) app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"]) +app.include_router(contact_router, prefix="/api/contact", tags=["Kontakt"]) app.include_router(invoices_router) @@ -408,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "985" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "986" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/contact.py b/backend/routes/contact.py new file mode 100644 index 0000000..ee33cf4 --- /dev/null +++ b/backend/routes/contact.py @@ -0,0 +1,52 @@ +""" +BAN YARO — Öffentliches Kontaktformular (kein Login erforderlich) +Für Impressum-Kontaktpflicht nach § 5 DDG. +""" + +from fastapi import APIRouter, Request +from pydantic import BaseModel, EmailStr, Field +from typing import Annotated + +from mailer import send_email, email_html +from ratelimit import check as rl_check + +router = APIRouter() + +CONTACT_MAIL = "hallo@banyaro.app" + + +class ContactIn(BaseModel): + name: Annotated[str, Field(min_length=2, max_length=100)] + email: EmailStr + subject: Annotated[str, Field(min_length=3, max_length=150)] + message: Annotated[str, Field(min_length=10, max_length=3000)] + + +@router.post("") +async def submit_contact(payload: ContactIn, request: Request): + rl_check(request, max_requests=3, window_seconds=3600, key=f"contact_{payload.email}") + + body = f""" +

    Neue Kontaktanfrage über das Impressum-Formular:

    + + + + + + + +
    Name{payload.name}
    E-Mail{payload.email}
    Betreff{payload.subject}
    +
    +{payload.message} +
    """ + + plain = f"Kontakt von {payload.name} ({payload.email})\nBetreff: {payload.subject}\n\n{payload.message}" + + await send_email( + CONTACT_MAIL, + f"Kontakt: {payload.subject} — {payload.name}", + email_html(body), + plain, + ) + return {"ok": True} diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7f2f4f3..ad4311a 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '985'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '986'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/impressum.js b/backend/static/js/pages/impressum.js index baacd1a..e05776d 100644 --- a/backend/static/js/pages/impressum.js +++ b/backend/static/js/pages/impressum.js @@ -24,11 +24,58 @@ window.Page_impressum = (() => {

    Kontakt

    -

    +

    E-Mail: hallo@banyaro.app
    - Wir antworten in der Regel innerhalb von 24 Stunden. + Oder nutze das Formular — wir antworten in der Regel innerhalb von 24 Stunden.

    + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    + + +
    @@ -72,7 +119,58 @@ window.Page_impressum = (() => { `; } + function _initContactForm(container) { + const form = container.querySelector('#contact-form'); + const statusEl = container.querySelector('#cf-status'); + const submitBtn = container.querySelector('#cf-submit'); + if (!form) return; + + form.addEventListener('submit', async e => { + e.preventDefault(); + const name = container.querySelector('#cf-name').value.trim(); + const email = container.querySelector('#cf-email').value.trim(); + const subject = container.querySelector('#cf-subject').value.trim(); + const message = container.querySelector('#cf-message').value.trim(); + + submitBtn.disabled = true; + submitBtn.textContent = 'Wird gesendet…'; + statusEl.style.display = 'none'; + + try { + const res = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, subject, message }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'Fehler beim Senden.'); + } + statusEl.style.display = 'block'; + statusEl.style.background = 'var(--c-success-bg, #f0fdf4)'; + statusEl.style.color = 'var(--c-success, #16a34a)'; + statusEl.textContent = '✓ Nachricht gesendet — wir melden uns bald!'; + form.reset(); + } catch (err) { + statusEl.style.display = 'block'; + statusEl.style.background = '#fef2f2'; + statusEl.style.color = '#dc2626'; + statusEl.textContent = err.message || 'Fehler beim Senden. Bitte versuche es später erneut.'; + submitBtn.disabled = false; + submitBtn.textContent = 'Nachricht senden'; + } + }); + } + + const _origInit = init; + function refresh() {} - return { init, refresh }; + return { + init(container) { + _origInit(container); + _initContactForm(container); + }, + refresh + }; })(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 27d268b..2f3405b 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v985'; +const CACHE_VERSION = 'by-v986'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 0c0daaad6b9db979897d148b7de0870ce18f31b6 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 16:53:38 +0200 Subject: [PATCH 051/113] =?UTF-8?q?Feat:=20Routen=20offline=20aufzeichnen?= =?UTF-8?q?=20=E2=80=94=20LocalStorage-Queue,=20Cache-Fallback,=20Auto-Syn?= =?UTF-8?q?c=20(SW=20by-v987)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/routes.js | 89 ++++++++++++++++++++++++++----- backend/static/sw.js | 3 +- 4 files changed, 79 insertions(+), 17 deletions(-) diff --git a/backend/main.py b/backend/main.py index ae5e58c..a1b570f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "986" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "987" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index ad4311a..98b2a41 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '986'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '987'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 80cffa9..de6fbe2 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -5,6 +5,40 @@ window.Page_routes = (() => { + const _CACHE_KEY = 'by_routes_cache'; + const _PENDING_KEY = 'by_routes_pending'; + + function _getPending() { + try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; } + } + function _setPending(list) { + try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {} + } + function _addPending(data) { + const list = _getPending(); + const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true, + created_at: new Date().toISOString(), user_id: null }; + list.push(entry); + _setPending(list); + return entry; + } + async function _syncPending() { + if (!navigator.onLine) return; + const list = _getPending(); + if (!list.length) return; + let ok = 0; + for (const r of [...list]) { + try { + const { id: _pid, _isPending, ...payload } = r; + await API.routes.create(payload); + _setPending(_getPending().filter(x => x.id !== r.id)); + ok++; + } catch {} + } + if (ok > 0) { UI.toast.success(`${ok} Route(n) synchronisiert.`); _loadData(); } + } + window.addEventListener('online', _syncPending); + let _container = null; let _appState = null; let _data = []; @@ -1011,7 +1045,7 @@ window.Page_routes = (() => { const btn = document.querySelector('[form="rk-rms-form"][type="submit"]'); const fd = UI.formData(e.target); await UI.asyncButton(btn, async () => { - const saved = await API.routes.create({ + const payload = { name: fd.name?.trim(), beschreibung: fd.beschreibung || null, gps_track: track, @@ -1024,7 +1058,15 @@ window.Page_routes = (() => { is_public: 'is_public' in fd, hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut', client_time: API.clientNow(), - }); + }; + if (!navigator.onLine) { + _addPending(payload); + UI.modal.close(); + UI.toast.success(`Route offline gespeichert — wird synchronisiert sobald Verbindung besteht.`); + _loadData(); + return; + } + const saved = await API.routes.create(payload); UI.modal.close(); UI.toast.success(`Route „${saved.name}" gespeichert!`); _loadData(); @@ -1209,20 +1251,36 @@ window.Page_routes = (() => { // Daten // ---------------------------------------------------------- async function _loadData() { + const _merge = (online) => { + const pending = _getPending(); + if (pending.length) _data = [...pending, ..._data]; + if (_appState.user && _browseMode === 'mine') + document.getElementById('rk-mine-group')?.style.setProperty('display', ''); + if (_browseMode === 'discover' && _userPos) + document.getElementById('rk-nearby-group')?.style.setProperty('display', ''); + if (!online && pending.length) + UI.toast.info('Offline — ' + pending.length + ' Route(n) warten auf Sync.'); + _applyFilter(); + }; try { _data = await API.routes.list(); - // "Meine Routen"-Filter nur zeigen wenn eingeloggt und im Mine-Modus - if (_appState.user && _browseMode === 'mine') { - document.getElementById('rk-mine-group')?.style.setProperty('display', ''); - } - // Standort-abhängiger Filter im Entdecken-Modus - if (_browseMode === 'discover' && _userPos) { - document.getElementById('rk-nearby-group')?.style.setProperty('display', ''); - } - _applyFilter(); - } catch (err) { + try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _data })); } catch {} + _merge(true); + } catch { + try { + const raw = localStorage.getItem(_CACHE_KEY); + if (raw) { + _data = JSON.parse(raw).data || []; + UI.toast.info('Offline — zeige zuletzt geladene Routen.'); + _merge(false); + return; + } + } catch {} + // Nur Pending-Routen zeigen wenn gar kein Cache + _data = _getPending(); + if (_data.length) { _merge(false); return; } document.getElementById('rk-grid').innerHTML = - `

    Fehler: ${UI.escape(err.message)}

    `; + `

    Offline — noch keine Routen gecacht.

    `; } } @@ -1369,10 +1427,13 @@ window.Page_routes = (() => { : ''; return ` -
    +
    ${previewContent}
    ${authorLine} + ${r._isPending ? `
    + ${UI.icon('cloud-arrow-up')} Sync ausstehend
    ` : ''}
    ${UI.escape(r.name)}
    ${dist ? _pill(dist, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} diff --git a/backend/static/sw.js b/backend/static/sw.js index 2f3405b..a546b58 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v986'; +const CACHE_VERSION = 'by-v987'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache @@ -138,6 +138,7 @@ const _CACHEABLE_GET = [ /^\/api\/training\/plan-progress/, /^\/api\/wiki\/rassen/, /^\/api\/dogs\/\d+\/diary\/stats/, + /^\/api\/routes$/, // Drei Welten — offline-fähig /^\/api\/streak\/\d+/, /^\/api\/forum\/threads/, From 53fcb61933fdc1c271771326c58111a360985190 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 17:02:26 +0200 Subject: [PATCH 052/113] =?UTF-8?q?Offline-Fallbacks=20f=C3=BCr=20diary,?= =?UTF-8?q?=20poison,=20map=20+=20SW-Erweiterung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sw.js: /api/places, /api/breeder/map-markers, /api/gassi-zeiten in _CACHEABLE_GET; /api/lost/report und /api/walks in _QUEUEABLE - diary.js: localStorage-Cache pro Hund, Fallback bei Offline mit Toast - poison.js: localStorage-Cache, Fallback bei Offline mit Toast (sicherheitsrelevant) - map.js: POI-Cache (places/poison/breeders) in localStorage, Offline-Toast + Fallback auf gecachte Daten --- backend/static/js/pages/diary.js | 19 +++++++- backend/static/js/pages/lost.js | 77 +++++++++++++++++++++++++++++- backend/static/js/pages/map.js | 41 ++++++++++++++-- backend/static/js/pages/poison.js | 15 ++++++ backend/static/js/pages/walks.js | 79 +++++++++++++++++++++++++++---- backend/static/sw.js | 5 ++ 6 files changed, 222 insertions(+), 14 deletions(-) diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index f04bf67..c708912 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -6,6 +6,8 @@ window.Page_diary = (() => { + const _CACHE_KEY = 'by_diary_cache'; + // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- @@ -324,6 +326,7 @@ window.Page_diary = (() => { async function _load() { const dog = _appState.activeDog; if (!dog) return; + const cacheKey = _CACHE_KEY + '_' + dog.id; try { const params = { limit: LIMIT, offset: _offset }; if (_searchQuery) params.q = _searchQuery; @@ -331,6 +334,10 @@ window.Page_diary = (() => { const batch = await API.diary.list(dog.id, params); _entries = _entries.concat(batch); + if (_offset === 0 && !_searchQuery && !_filterMilestone) { + try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data: batch })); } catch {} + } + // "Mehr laden" anzeigen wenn volle Page geladen wurde const loadMore = _container.querySelector('#diary-load-more'); if (loadMore) { @@ -339,7 +346,17 @@ window.Page_diary = (() => { // Stats-Bar befüllen _renderStatsBar(); - } catch (err) { + } catch { + try { + const raw = localStorage.getItem(cacheKey); + if (raw) { + const cached = JSON.parse(raw).data || []; + _entries = cached; + _renderStatsBar(); + UI.toast.info('Offline — zeige zuletzt geladene Einträge.'); + return; + } + } catch {} UI.toast.error('Einträge konnten nicht geladen werden.'); } } diff --git a/backend/static/js/pages/lost.js b/backend/static/js/pages/lost.js index 086224e..37daa9f 100644 --- a/backend/static/js/pages/lost.js +++ b/backend/static/js/pages/lost.js @@ -5,6 +5,43 @@ window.Page_lost = (() => { + // ---------------------------------------------------------- + // OFFLINE-CACHE + // ---------------------------------------------------------- + const _CACHE_KEY = 'by_lost_cache'; + const _PENDING_KEY = 'by_lost_pending'; + + function _getPending() { + try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; } + } + function _setPending(list) { + try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {} + } + function _addPending(data) { + const list = _getPending(); + const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true, + created_at: new Date().toISOString() }; + list.push(entry); + _setPending(list); + return entry; + } + async function _syncPending() { + if (!navigator.onLine) return; + const list = _getPending(); + if (!list.length) return; + let ok = 0; + for (const item of [...list]) { + try { + const { id: _pid, _isPending, ...payload } = item; + await API.lost.report(payload); + _setPending(_getPending().filter(x => x.id !== item.id)); + ok++; + } catch {} + } + if (ok > 0) { UI.toast.success(`${ok} Meldung(en) synchronisiert.`); _loadReports(); } + } + window.addEventListener('online', _syncPending); + // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- @@ -179,8 +216,14 @@ window.Page_lost = (() => { return; } + const pending = _getPending().map(p => ({ + ...p, + distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon), + })); try { - _reports = await API.lost.list(_userPos.lat, _userPos.lon, 25); + const fetched = await API.lost.list(_userPos.lat, _userPos.lon, 25); + try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {} + _reports = [...pending, ...fetched]; _renderMarkers(); _renderHeld(); _renderList(); @@ -191,6 +234,26 @@ window.Page_lost = (() => { : 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾'; } } catch { + try { + const raw = localStorage.getItem(_CACHE_KEY); + if (raw) { + _reports = [...pending, ...(JSON.parse(raw).data || [])]; + _renderMarkers(); + _renderHeld(); + _renderList(); + _updateBadge(_reports.length); + if (infoEl) infoEl.textContent = 'Offline — zeige zuletzt geladene Meldungen.'; + return; + } + } catch {} + _reports = pending; + if (pending.length) { + _renderMarkers(); + _renderHeld(); + _renderList(); + _updateBadge(_reports.length); + return; + } UI.toast.error('Meldungen konnten nicht geladen werden.'); } } @@ -332,6 +395,7 @@ window.Page_lost = (() => { Gemeldet ${_fmtDate(r.created_at)} ${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
    + ${r._isPending ? `
    ⏳ Sync ausstehend
    ` : ''} ${_appState.user ? `
    @@ -1128,15 +1182,24 @@ window.Page_walks = (() => { const idx = _data.findIndex(w => w.id === walk.id); if (idx !== -1) _data[idx] = { ..._data[idx], ...updated }; UI.toast.success('Treffen aktualisiert.'); + UI.modal.close(); + _renderList(); + _renderMarkers(); } else { + if (!navigator.onLine) { + _addPending(payload); + UI.modal.close(); + UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.'); + _loadData(); + return; + } const created = await API.walks.create(payload); _data.unshift({ ...created, teilnehmer_count: 0 }); UI.toast.success('Treffen geplant! 🎉'); + UI.modal.close(); + _renderList(); + _renderMarkers(); } - - UI.modal.close(); - _renderList(); - _renderMarkers(); }); }); } diff --git a/backend/static/sw.js b/backend/static/sw.js index a546b58..cf7f2ba 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -119,6 +119,8 @@ const _QUEUEABLE = [ { re: /^\/api\/training\/sessions$/, methods: ['POST'] }, { re: /^\/api\/training\/progress$/, methods: ['POST'] }, { re: /^\/api\/poison$/, methods: ['POST'] }, + { re: /^\/api\/lost\/report$/, methods: ['POST'] }, + { re: /^\/api\/walks$/, methods: ['POST'] }, ]; function _isQueueable(pathname, method) { return _QUEUEABLE.some(q => q.methods.includes(method) && q.re.test(pathname)); @@ -139,6 +141,9 @@ const _CACHEABLE_GET = [ /^\/api\/wiki\/rassen/, /^\/api\/dogs\/\d+\/diary\/stats/, /^\/api\/routes$/, + /^\/api\/places$/, + /^\/api\/breeder\/map-markers$/, + /^\/api\/gassi-zeiten/, // Drei Welten — offline-fähig /^\/api\/streak\/\d+/, /^\/api\/forum\/threads/, From 8ad3ca8a74d9903dc04df6aafc0bc3b4909076d5 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 17:04:06 +0200 Subject: [PATCH 053/113] =?UTF-8?q?Perf:=20Offline-Support=20alle=20Priori?= =?UTF-8?q?t=C3=A4ts-Seiten=20=E2=80=94=20Cache+Queue+Sync=20(SW=20by-v988?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/main.py b/backend/main.py index a1b570f..16a03e5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "987" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "988" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 98b2a41..70a91b3 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '987'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '988'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/sw.js b/backend/static/sw.js index cf7f2ba..d01b2bd 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v987'; +const CACHE_VERSION = 'by-v988'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From f0c1ee338632ada961bbf2d707f805a3457522d5 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 17:25:24 +0200 Subject: [PATCH 054/113] =?UTF-8?q?Fix:=20Offline-UX=20=E2=80=94=20?= =?UTF-8?q?=F0=9F=93=A1=20statt=20=F0=9F=9A=A7=20bei=20offline-Seiten,=20s?= =?UTF-8?q?chnellerer=20Warm-up,=20mehr=20Endpoints=20gecacht=20(SW=20by-v?= =?UTF-8?q?989)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 2 +- backend/static/js/app.js | 25 +++++++++++++++---------- backend/static/js/pages/routes.js | 3 ++- backend/static/sw.js | 12 ++++++++---- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/backend/main.py b/backend/main.py index 16a03e5..212bcbf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "988" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "989" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 70a91b3..4e8b66a 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '988'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '989'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen @@ -266,10 +266,13 @@ const App = (() => { page.module = {}; // verhindert erneutes Laden } } catch { + const _offline = !navigator.onLine; container.innerHTML = UI.emptyState({ - icon: '🚧', + icon: _offline ? '📡' : '🚧', title: pages[pageId].title, - text: 'Diese Seite ist noch in Entwicklung.', + text: _offline + ? 'Diese Seite ist offline nicht verfügbar. Bitte öffne sie einmal mit Internetverbindung, damit sie gecacht wird.' + : 'Diese Seite ist noch in Entwicklung.', }); page.module = {}; } finally { @@ -1141,17 +1144,19 @@ const App = (() => { window.App = App; // Worlds kann App.navigate() aufrufen // App starten -// Prioritäts-Seiten im Hintergrund vorladen (3s nach Start, damit Hauptinhalt nicht blockiert) +// Prioritäts-Seiten im Hintergrund vorladen (1s nach Start) window.addEventListener('load', () => { setTimeout(() => { - if (!('caches' in window)) return; - ['admin','erste-hilfe','diary','map','walks','routes','poison','lost'].forEach(page => { + if (!navigator.onLine) return; + // Page-Scripts cachen + [ + 'admin','erste-hilfe','diary','map','walks','routes','poison','lost', + 'expenses','wetter','forum','health','uebungen','trainingsplaene','notes', + ].forEach(page => { const key = `Page_${page.replace(/-/g,'_')}`; - if (!window[key]) { - fetch(`/js/pages/${page}.js?v=${APP_VER}`).catch(() => {}); - } + if (!window[key]) fetch(`/js/pages/${page}.js?v=${APP_VER}`).catch(() => {}); }); - }, 3000); + }, 1000); }); document.addEventListener('DOMContentLoaded', () => { diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index de6fbe2..4689ded 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -668,7 +668,8 @@ window.Page_routes = (() => { if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; } if (_recOvl) return; - await UI.loadLeaflet?.() ?? Promise.resolve(); + try { await (UI.loadLeaflet?.() ?? Promise.resolve()); } + catch { UI.toast.warning('Karte offline nicht verfügbar — GPS-Aufzeichnung läuft trotzdem.'); } const ovl = document.createElement('div'); ovl.id = 'rk-rec-ovl'; diff --git a/backend/static/sw.js b/backend/static/sw.js index d01b2bd..e654eab 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v988'; +const CACHE_VERSION = 'by-v989'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache @@ -140,10 +140,14 @@ const _CACHEABLE_GET = [ /^\/api\/training\/plan-progress/, /^\/api\/wiki\/rassen/, /^\/api\/dogs\/\d+\/diary\/stats/, - /^\/api\/routes$/, - /^\/api\/places$/, - /^\/api\/breeder\/map-markers$/, + /^\/api\/routes/, + /^\/api\/places/, + /^\/api\/breeder\/map-markers/, /^\/api\/gassi-zeiten/, + /^\/api\/poison/, + /^\/api\/walks/, + /^\/api\/lost/, + /^\/api\/expenses/, // Drei Welten — offline-fähig /^\/api\/streak\/\d+/, /^\/api\/forum\/threads/, From be12550df180ff7319c34d04a8f7a687427462ad Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 17:37:16 +0200 Subject: [PATCH 055/113] =?UTF-8?q?Fix:=20Lost-Hund=20=E2=80=94=20kein=20D?= =?UTF-8?q?oppeleintrag=20nach=20Sync,=20pulsierender=20Marker,=20Verwerfe?= =?UTF-8?q?n-Button,=2020km-Alert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deduplication in _loadReports(): Pending-Einträge die bereits auf dem Server sind (Race-Condition beim Sync) werden automatisch aus dem Pending-Store entfernt - Verwerfen-Button für offline-gespeicherte Meldungen (pending), Notiz-Button nur für Server-Einträge sichtbar - Pulsierender Kreis-Marker (CSS @keyframes by-lost-ping) statt statischem Pin; Pending-Einträge in Orange, Server-Einträge in Rot - Card-Click für pending deaktiviert (kein Detail-Modal für unsynchronisierte Daten) - worlds.js: Alert-Radius für vermisste Hunde von 5 auf 20 km erhöht (wie Giftköder) - SW by-v990, APP_VER 990 --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/lost.js | 128 +++++++++++++++++++++++--------- backend/static/js/worlds.js | 2 +- backend/static/sw.js | 2 +- 5 files changed, 97 insertions(+), 39 deletions(-) diff --git a/backend/main.py b/backend/main.py index 212bcbf..bc3f106 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "989" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "990" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4e8b66a..4cd3b61 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '989'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '990'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/lost.js b/backend/static/js/pages/lost.js index 37daa9f..e4be5d8 100644 --- a/backend/static/js/pages/lost.js +++ b/backend/static/js/pages/lost.js @@ -45,14 +45,29 @@ window.Page_lost = (() => { // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- - let _container = null; - let _appState = null; - let _map = null; - let _markers = []; - let _userMarker = null; - let _reports = []; - let _userPos = null; - let _leafletLoaded = false; + let _container = null; + let _appState = null; + let _map = null; + let _markers = []; + let _userMarker = null; + let _reports = []; + let _userPos = null; + let _leafletLoaded = false; + let _stylesInjected = false; + + function _injectStyles() { + if (_stylesInjected) return; + _stylesInjected = true; + const s = document.createElement('style'); + s.textContent = ` + @keyframes by-lost-ping { + 0% { transform: scale(0.9); opacity: 0.7; } + 70% { transform: scale(2.2); opacity: 0; } + 100% { transform: scale(2.2); opacity: 0; } + } + `; + document.head.appendChild(s); + } // ---------------------------------------------------------- // INIT @@ -216,13 +231,23 @@ window.Page_lost = (() => { return; } - const pending = _getPending().map(p => ({ - ...p, - distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon), - })); try { const fetched = await API.lost.list(_userPos.lat, _userPos.lon, 25); try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {} + + // Remove pending items already on the server (race: sync completed during fetch) + const rawPending = _getPending(); + const dedupedPending = rawPending.filter(p => + !fetched.some(f => f.name === p.name && + Math.abs(f.lat - p.lat) < 0.0001 && + Math.abs(f.lon - p.lon) < 0.0001) + ); + if (dedupedPending.length < rawPending.length) _setPending(dedupedPending); + + const pending = dedupedPending.map(p => ({ + ...p, + distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon), + })); _reports = [...pending, ...fetched]; _renderMarkers(); _renderHeld(); @@ -234,10 +259,15 @@ window.Page_lost = (() => { : 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾'; } } catch { + const offline_pending = _getPending().map(p => ({ + ...p, + distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon), + })); try { const raw = localStorage.getItem(_CACHE_KEY); if (raw) { - _reports = [...pending, ...(JSON.parse(raw).data || [])]; + const cached = JSON.parse(raw).data || []; + _reports = [...offline_pending, ...cached]; _renderMarkers(); _renderHeld(); _renderList(); @@ -246,8 +276,8 @@ window.Page_lost = (() => { return; } } catch {} - _reports = pending; - if (pending.length) { + _reports = offline_pending; + if (offline_pending.length) { _renderMarkers(); _renderHeld(); _renderList(); @@ -263,24 +293,33 @@ window.Page_lost = (() => { // ---------------------------------------------------------- function _renderMarkers() { if (!_map || !window.L) return; + _injectStyles(); _markers.forEach(m => _map.removeLayer(m)); _markers = []; _reports.forEach(r => { + const dotColor = r._isPending ? '#d97706' : '#e74c3c'; + const ringColor = r._isPending ? 'rgba(217,119,6,0.35)' : 'rgba(231,76,60,0.35)'; const icon = L.divIcon({ className : '', - html : `
    🐕
    `, - iconSize : [34, 34], - iconAnchor : [17, 17], + html : ` +
    +
    +
    🐕
    +
    `, + iconSize : [44, 44], + iconAnchor : [22, 22], }); const distStr = r.distanz_m !== undefined - ? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) + ? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) : ''; const marker = L.marker([r.lat, r.lon], { icon }) @@ -289,10 +328,11 @@ window.Page_lost = (() => { 🔍 ${_escape(r.name)}
    ${r.rasse ? _escape(r.rasse) + '
    ' : ''} ${distStr ? `📍 ${distStr} entfernt
    ` : ''} + ${r._isPending ? '⏳ Sync ausstehend
    ' : ''} 📅 ${_fmtDate(r.created_at)} `); - marker.on('click', () => _openDetail(r)); + if (!r._isPending) marker.on('click', () => _openDetail(r)); _markers.push(marker); }); } @@ -334,10 +374,19 @@ window.Page_lost = (() => { listEl.innerHTML = _reports.map(r => _reportCard(r)).join(''); listEl.querySelectorAll('[data-lost-id]').forEach(card => { card.addEventListener('click', () => { - const r = _reports.find(x => x.id === parseInt(card.dataset.lostId)); + const id = card.dataset.lostId; + const r = _reports.find(x => String(x.id) === id && !x._isPending); if (r) _openDetail(r); }); }); + listEl.querySelectorAll('.lost-discard-btn').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const pid = btn.dataset.pendingId; + _setPending(_getPending().filter(x => x.id !== pid)); + _loadReports(); + }); + }); listEl.querySelectorAll('.lost-note-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); @@ -395,15 +444,24 @@ window.Page_lost = (() => { Gemeldet ${_fmtDate(r.created_at)} ${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
    - ${r._isPending ? `
    ⏳ Sync ausstehend
    ` : ''} - ${_appState.user ? `
    - -
    ` : ''} + ${r._isPending + ? `
    + ⏳ Sync ausstehend + +
    ` + : (_appState.user ? `
    + +
    ` : '')}
    diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index e343655..35cb433 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1691,7 +1691,7 @@ window.Worlds = (() => { const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 }); const [p, l] = await Promise.allSettled([ API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []), - API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []), + API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=20`).catch(() => []), ]); if (p.value?.length) out.push({ icon:'skull', color:'#EF4444', title:'Giftköder in der Nähe', sub:`${p.value.length} Meldung${p.value.length>1?'en':''}`, page:'poison' }); if (l.value?.length) out.push({ icon:'dog', color:'#3B82F6', title:'Verlorener Hund', sub:`${l.value.length} Meldung${l.value.length>1?'en':''}`, page:'lost' }); diff --git a/backend/static/sw.js b/backend/static/sw.js index e654eab..ab84e7a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v989'; +const CACHE_VERSION = 'by-v990'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From f2856b8acbc4947b245ef5d128ee951840d6e37b Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 17:44:59 +0200 Subject: [PATCH 056/113] =?UTF-8?q?Fix:=20Lost=20=E2=80=94=20Puls-Animatio?= =?UTF-8?q?n=20(box-shadow),=20false-offline,=20Pending-Guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pulsierender Marker: Wechsel von position:absolute-Ring auf box-shadow-Animation (by-lost-pulse-r/p), kein Overflow-Problem mit Leaflet divIcon, iOS-kompatibel - navigator.onLine iOS-Falsch-Positiv: Formular-Submit versucht API zuerst, fällt nur bei TypeError (fetch failed) auf Pending-Modus zurück - _openDetail(): früher Return für Pending-Einträge (verhindert delete mit string-ID "pending_..." → Backend-Fehler "unable to parse integer") - SW by-v991, APP_VER 991 --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/lost.js | 62 +++++++++++++++++---------------- backend/static/sw.js | 2 +- 4 files changed, 35 insertions(+), 33 deletions(-) diff --git a/backend/main.py b/backend/main.py index bc3f106..ccf969b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "990" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "991" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4cd3b61..7fdd413 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '990'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '991'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/lost.js b/backend/static/js/pages/lost.js index e4be5d8..6f8fe0c 100644 --- a/backend/static/js/pages/lost.js +++ b/backend/static/js/pages/lost.js @@ -60,10 +60,13 @@ window.Page_lost = (() => { _stylesInjected = true; const s = document.createElement('style'); s.textContent = ` - @keyframes by-lost-ping { - 0% { transform: scale(0.9); opacity: 0.7; } - 70% { transform: scale(2.2); opacity: 0; } - 100% { transform: scale(2.2); opacity: 0; } + @keyframes by-lost-pulse-r { + 0%,100% { box-shadow: 0 0 0 0 rgba(231,76,60,.55), 0 2px 6px rgba(0,0,0,.3); } + 50% { box-shadow: 0 0 0 11px rgba(231,76,60,0), 0 2px 6px rgba(0,0,0,.3); } + } + @keyframes by-lost-pulse-p { + 0%,100% { box-shadow: 0 0 0 0 rgba(217,119,6,.55), 0 2px 6px rgba(0,0,0,.3); } + 50% { box-shadow: 0 0 0 11px rgba(217,119,6,0), 0 2px 6px rgba(0,0,0,.3); } } `; document.head.appendChild(s); @@ -165,6 +168,7 @@ window.Page_lost = (() => { // KARTE INITIALISIEREN // ---------------------------------------------------------- function _initMap() { + _injectStyles(); const mapEl = document.getElementById('lost-map'); if (!mapEl || !window.L || _map) return; @@ -293,29 +297,21 @@ window.Page_lost = (() => { // ---------------------------------------------------------- function _renderMarkers() { if (!_map || !window.L) return; - _injectStyles(); _markers.forEach(m => _map.removeLayer(m)); _markers = []; _reports.forEach(r => { - const dotColor = r._isPending ? '#d97706' : '#e74c3c'; - const ringColor = r._isPending ? 'rgba(217,119,6,0.35)' : 'rgba(231,76,60,0.35)'; + const dotColor = r._isPending ? '#d97706' : '#e74c3c'; + const anim = r._isPending ? 'by-lost-pulse-p' : 'by-lost-pulse-r'; const icon = L.divIcon({ className : '', - html : ` -
    -
    -
    🐕
    -
    `, - iconSize : [44, 44], - iconAnchor : [22, 22], + font-size:17px;border:2px solid #fff; + animation:${anim} 1.8s ease-in-out infinite">🐕`, + iconSize : [34, 34], + iconAnchor : [17, 17], }); const distStr = r.distanz_m !== undefined @@ -472,6 +468,7 @@ window.Page_lost = (() => { // DETAIL-MODAL // ---------------------------------------------------------- function _openDetail(r) { + if (r._isPending) return; // Pending-Einträge haben keine Server-ID const isOwn = _appState.user && _appState.user.id === r.user_id; const isAdmin = _appState.user?.rolle === 'admin'; const distStr = r.distanz_m !== undefined @@ -754,19 +751,24 @@ window.Page_lost = (() => { client_time : API.clientNow(), }; - if (!navigator.onLine) { - const pending = _addPending(payload); - pending.distanz_m = _userPos - ? Math.round(_haversine(_userPos.lat, _userPos.lon, pending.lat, pending.lon)) - : 0; - UI.modal.close(); - UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.'); - _loadReports(); - return; + let created; + try { + created = await API.lost.report(payload); + } catch (netErr) { + // Netzwerkfehler (TypeError = fetch failed) → offline speichern + if (netErr instanceof TypeError || !navigator.onLine) { + const pending = _addPending(payload); + pending.distanz_m = _userPos + ? Math.round(_haversine(_userPos.lat, _userPos.lon, pending.lat, pending.lon)) + : 0; + UI.modal.close(); + UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.'); + _loadReports(); + return; + } + throw netErr; // API-Fehler (z.B. 422) → weitergeben } - const created = await API.lost.report(payload); - // Foto hochladen if (photoInput?.files[0]) { try { diff --git a/backend/static/sw.js b/backend/static/sw.js index ab84e7a..d451c5b 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v990'; +const CACHE_VERSION = 'by-v991'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 32fde79a409b464529d729a793bcd82359ded261 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 17:56:29 +0200 Subject: [PATCH 057/113] =?UTF-8?q?Fix:=20=5Fqueued-Guard=20in=20poison/wa?= =?UTF-8?q?lks/diary=20=E2=80=94=20kein=20Crash=20bei=20SW-gequeuten=20Req?= =?UTF-8?q?uests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wenn der SW einen POST in die Offline-Queue legt, gibt api.js { _queued: true } zurück (202). Ohne Guard versuchten poison/walks/diary den Response als echtes Server-Objekt zu nutzen → undefined lat/lon → Leaflet-Crash, undefined id → Upload-Fehler. Nach dem Guard wird das Modal nur geschlossen; der QUEUE_PROCESSED-Toast informiert den User sobald synchronisiert. - poison.js: _queued guard nach API.poison.report() - walks.js: _queued guard + try-catch statt navigator.onLine - diary.js: _queued guard nach API.diary.create() - SW by-v992, APP_VER 992 --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/diary.js | 1 + backend/static/js/pages/poison.js | 3 +++ backend/static/js/pages/walks.js | 20 +++++++++++++------- backend/static/sw.js | 2 +- 6 files changed, 20 insertions(+), 10 deletions(-) diff --git a/backend/main.py b/backend/main.py index ccf969b..e5e68dc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "991" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "992" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7fdd413..28492bb 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '991'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '992'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index c708912..d15c9b5 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -1765,6 +1765,7 @@ window.Page_diary = (() => { UI.toast.success('Eintrag gespeichert.'); } else { const created = await API.diary.create(_appState.activeDog.id, payload); + if (created?._queued) { UI.modal.close(); return; } if (_newFiles.length > 0) { const { uploaded, exifGps } = await _uploadNewFiles(created.id); created.media_items = uploaded; diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js index 5af9a34..f4b1dd4 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -543,6 +543,9 @@ window.Page_poison = (() => { const created = await API.poison.report(payload); + // SW hat Request in Queue gelegt (offline) — Modal schließen, kein Marker + if (created?._queued) { UI.modal.close(); return; } + // Foto hochladen if (photoInput?.files[0]) { try { diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index feaeb62..e0c6c40 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -1186,14 +1186,20 @@ window.Page_walks = (() => { _renderList(); _renderMarkers(); } else { - if (!navigator.onLine) { - _addPending(payload); - UI.modal.close(); - UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.'); - _loadData(); - return; + let created; + try { + created = await API.walks.create(payload); + } catch (netErr) { + if (netErr instanceof TypeError || !navigator.onLine) { + _addPending(payload); + UI.modal.close(); + UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.'); + _loadData(); + return; + } + throw netErr; } - const created = await API.walks.create(payload); + if (created?._queued) { UI.modal.close(); _loadData(); return; } _data.unshift({ ...created, teilnehmer_count: 0 }); UI.toast.success('Treffen geplant! 🎉'); UI.modal.close(); diff --git a/backend/static/sw.js b/backend/static/sw.js index d451c5b..cee7de1 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v991'; +const CACHE_VERSION = 'by-v992'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 08786844024b86e0aaa5f683ac0bde2f05fa739a Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 18:03:28 +0200 Subject: [PATCH 058/113] =?UTF-8?q?Feature:=20Giftk=C3=B6der=20=E2=80=94?= =?UTF-8?q?=20Danke-Overlay=20nach=20erfolgreicher=20Meldung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Statt sofort zu schließen zeigt das Modal nach dem Submit eine Bestätigung: 'Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung. Vielen Dank, dass du die Community schützt!' Auto-Close nach 5 Sekunden, OK-Button zum sofortigen Schließen. Bei gequeuter Meldung (offline) zusätzlicher Hinweis auf spätere Synchronisierung. SW by-v993, APP_VER 993 --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/poison.js | 43 ++++++++++++++++++++++++++----- backend/static/sw.js | 2 +- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/backend/main.py b/backend/main.py index e5e68dc..42f22ba 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "992" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "993" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 28492bb..aa2c342 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '992'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '993'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js index f4b1dd4..2a3988b 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -543,8 +543,11 @@ window.Page_poison = (() => { const created = await API.poison.report(payload); - // SW hat Request in Queue gelegt (offline) — Modal schließen, kein Marker - if (created?._queued) { UI.modal.close(); return; } + // SW hat Request in Queue gelegt (offline) + if (created?._queued) { + _showPoisonThanks(true); + return; + } // Foto hochladen if (photoInput?.files[0]) { @@ -558,8 +561,7 @@ window.Page_poison = (() => { } } - // Distanz client-seitig berechnen (für sofortige Anzeige) - // _userPos aktualisieren falls Picker neuen Standort geliefert hat + // Distanz client-seitig berechnen if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon }; created.distanz_m = _userPos ? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon)) @@ -571,12 +573,41 @@ window.Page_poison = (() => { _updateBadge(_reports.length); App.checkNearbyAlerts(); App.callModule('map', 'refresh'); - UI.toast.success('Giftköder gemeldet! Danke für die Warnung.'); - UI.modal.close(); + _showPoisonThanks(false); }); }); } + // ---------------------------------------------------------- + // DANKE-OVERLAY nach Giftköder-Meldung + // ---------------------------------------------------------- + function _showPoisonThanks(isQueued) { + const offlineNote = isQueued + ? `

    + 📡 Wird synchronisiert sobald du wieder online bist. +

    ` + : ''; + UI.modal.open({ + title: '✅ Danke für deine Meldung!', + body: ` +
    +
    🚨
    +

    + Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung. +

    +

    + Vielen Dank, dass du die Community schützt! 🐾 +

    + ${offlineNote} +
    + `, + footer: ``, + }); + document.getElementById('poison-thanks-ok')?.addEventListener('click', UI.modal.close); + setTimeout(() => UI.modal.close(), 5000); + } + // ---------------------------------------------------------- // BADGE (Sidebar + Bottom-Nav) // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index cee7de1..aa5a66d 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v992'; +const CACHE_VERSION = 'by-v993'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 57192ea010a9598fa6d95e5e9e23f116df7bf49e Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 18:11:52 +0200 Subject: [PATCH 059/113] =?UTF-8?q?Fix:=20Routen-Aufzeichnung=20offline=20?= =?UTF-8?q?=E2=80=94=20Buttons=20Abbruch/Start=20reagieren=20nicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L.map() warf ReferenceError wenn Leaflet offline nicht geladen → _openRecOvl() crashte, Event-Listener für #rk-rec-cancel und #rk-rec-startbtn wurden nie angehängt. Fix: - Listener direkt nach appendChild() registrieren (vor jeder async-Operation) - Map-Setup in try/catch; bei fehlendem Leaflet: Offline-Platzhalter im Map-Bereich - _recMap?.setView / _recLocMarker?.setLatLng mit Optional Chaining (null-safe) - SW by-v994, APP_VER 994 --- backend/main.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/routes.js | 39 ++++++++++++++++++++----------- backend/static/sw.js | 2 +- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/backend/main.py b/backend/main.py index 42f22ba..c2a6929 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "993" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "994" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index aa2c342..67c2516 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '993'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '994'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 4689ded..7213da3 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -726,24 +726,37 @@ window.Page_routes = (() => { document.body.appendChild(ovl); _recOvl = ovl; - const pos = _userPos || { lat: 48.1, lon: 11.5 }; - _recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false }) - .setView([pos.lat, pos.lon], 15); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap); - _recLocMarker = L.circleMarker([pos.lat, pos.lon], { - radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1 - }).addTo(_recMap); + // Listener sofort nach DOM-Einfügen — nicht nach async-Operationen + ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean()); + ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl); - // Get accurate position + // Map-Setup: Leaflet könnte offline fehlen → alles in try/catch + const pos = _userPos || { lat: 48.1, lon: 11.5 }; + try { + if (!window.L) throw new Error('Leaflet not loaded'); + _recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false }) + .setView([pos.lat, pos.lon], 15); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap); + _recLocMarker = L.circleMarker([pos.lat, pos.lon], { + radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1 + }).addTo(_recMap); + } catch { + const mapWrap = ovl.querySelector('#rk-rec-map-wrap'); + if (mapWrap) mapWrap.innerHTML = + `
    + 📡 + Karte offline nicht verfügbar — GPS läuft trotzdem +
    `; + } + + // Genaueren Standort nachladen (best-effort, klappt auch offline via gespeichertem GPS) try { const p = await API.getLocation(); _userPos = p; - _recMap.setView([p.lat, p.lon], 16); - _recLocMarker.setLatLng([p.lat, p.lon]); + _recMap?.setView([p.lat, p.lon], 16); + _recLocMarker?.setLatLng([p.lat, p.lon]); } catch {} - - ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean()); - ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl); } async function _startRecInOvl() { diff --git a/backend/static/sw.js b/backend/static/sw.js index aa5a66d..bb12985 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v993'; +const CACHE_VERSION = 'by-v994'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From c59326af17a5d93aae1f82bb2bb95409eca3e8a2 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 18:18:22 +0200 Subject: [PATCH 060/113] Fix+Polish: Phosphor-Icons Danke-Overlay, Quartalsbericht paid_amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Giftköder Danke-Overlay (poison.js): - Emoji 🚨/🐾/📡 durch Phosphor-Icons ersetzt: siren, paw-print, wifi-slash Quartalsbericht (invoices.py + admin.js): - Backend: _effective_gross() — für bezahlte Rechnungen wird paid_amount statt amount_gross für die Quartalssumme verwendet (Kulanz/Teilzahlung korrekt) - Admin-Preview: effectiveAmt in der Vorschau-Tabelle, bei Abweichung Hinweis "(RG: xx,xx €)" für Nachvollziehbarkeit - CSV: Spalte "Betrag (eingegangen)" + separate Spalte "Rechnungsbetrag" - SW by-v995, APP_VER 995 --- backend/main.py | 2 +- backend/routes/invoices.py | 8 +++++++- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 19 ++++++++++++------- backend/static/js/pages/poison.js | 16 ++++++++++------ backend/static/sw.js | 2 +- 6 files changed, 32 insertions(+), 17 deletions(-) diff --git a/backend/main.py b/backend/main.py index c2a6929..2a4beb4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "994" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "995" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 53562f0..28f3d15 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -486,7 +486,13 @@ def get_quarterly(year: int, q: int, admin=Depends(require_admin)): entries.sort(key=lambda e: (e.get("created_at") or "")) # Summen: alle Einträge — Storno (-) und Original (+) heben sich gegenseitig auf - total_gross = sum(e.get("amount_gross") or 0 for e in entries) + # Für bezahlte Rechnungen den tatsächlich eingegangenen Betrag verwenden + def _effective_gross(e): + if e.get("status") == "paid" and e.get("paid_amount") is not None: + return e["paid_amount"] + return e.get("amount_gross") or 0 + + total_gross = sum(_effective_gross(e) for e in entries) total_tax = sum(e.get("tax_amount") or 0 for e in entries) return { diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 67c2516..91ccd34 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '994'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '995'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index c3ae84f..b7acbf5 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -4415,17 +4415,19 @@ window.Page_admin = (() => { const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; const statusLabel = { paid: 'Bezahlt', sent: 'Versendet', cancelled: 'Storniert (Original)', storno: 'Stornorechnung' }; - const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Betrag;Eingegangener Betrag;Status;Versendet am;Zahlungseingang\n'; - const csvRows = data.invoices.map(inv => [ + const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Betrag (eingegangen);Rechnungsbetrag;Status;Versendet am;Zahlungseingang\n'; + const csvRows = data.invoices.map(inv => { + const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross; + return [ inv.invoice_number, inv.recipient_name, inv.recipient_email || '', fmtDate(inv.created_at), inv.service_period || '', + fmtEur(effectiveAmt), fmtEur(inv.amount_gross), - inv.paid_amount != null ? fmtEur(inv.paid_amount) : '', statusLabel[inv.status] || inv.status, fmtDate(inv.sent_at), fmtDate(inv.paid_at) - ].map(escape).join(';') - ).join('\n'); + ].map(escape).join(';'); + }).join('\n'); const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); @@ -4457,12 +4459,15 @@ window.Page_admin = (() => { const sL = { draft:'Entwurf', sent:'Versendet', paid:'Bezahlt', cancelled:'Storniert (Orig.)', storno:'Stornorechnung' }; const rows2 = data.invoices.map((inv, i) => { const isStorno = inv.status === 'storno'; - const amtColor = isStorno ? 'color:var(--c-danger)' : (inv.amount_gross < 0 ? 'color:var(--c-danger)' : ''); + const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross; + const amtColor = isStorno ? 'color:var(--c-danger)' : (effectiveAmt < 0 ? 'color:var(--c-danger)' : ''); + const amtNote = (inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01) + ? ` (RG: ${_fmtE(inv.amount_gross)})` : ''; return `
${_esc(inv.invoice_number)} ${_esc(inv.recipient_name)}${_fmtE(inv.amount_gross)}${_fmtE(effectiveAmt)}${amtNote} ${sL[inv.status]||inv.status} ${_fmtD(inv.created_at)}