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 `