diff --git a/backend/database.py b/backend/database.py index ec362bc..f5aee7b 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2398,52 +2398,6 @@ 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 03d9228..344fe4f 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -6,13 +6,11 @@ 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 @@ -35,7 +33,9 @@ APP_URL = os.getenv("APP_URL", "https://banyaro.app") # ------------------------------------------------------------------ # Brevo REST-API # ------------------------------------------------------------------ -async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: list | None = None): +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" from_raw = SMTP_FROM if "<" in from_raw: from_name = from_raw[:from_raw.index("<")].strip() @@ -52,14 +52,6 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: "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, @@ -72,25 +64,13 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: # ------------------------------------------------------------------ # SMTP Fallback # ------------------------------------------------------------------ -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")) - +def _send_smtp_sync(to: str, subject: str, html: str, plain: str): + msg = MIMEMultipart("alternative") 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() @@ -103,10 +83,10 @@ def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: l # ------------------------------------------------------------------ # Öffentliche Funktion # ------------------------------------------------------------------ -async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None): +async def send_email(to: str, subject: str, html: str, plain: str = ""): if BREVO_API_KEY: try: - await _send_brevo(to, subject, html, plain, attachments) + await _send_brevo(to, subject, html, plain) logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}") return except Exception as e: @@ -116,9 +96,7 @@ async def send_email(to: str, subject: str, html: str, plain: str = "", attachme if SMTP_HOST: loop = asyncio.get_event_loop() try: - await loop.run_in_executor( - None, _send_smtp_sync, to, subject, html, plain, attachments - ) + await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain) 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 3d79bb8..8f75144 100644 --- a/backend/main.py +++ b/backend/main.py @@ -253,7 +253,6 @@ 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"]) @@ -318,7 +317,6 @@ 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) # ------------------------------------------------------------------ @@ -408,7 +406,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 = "965" # 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..a8fa045 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1,8 +1,5 @@ """BAN YARO — Admin / Moderator Backend""" import asyncio -import csv -import io -import logging import os import sys import time @@ -11,14 +8,11 @@ 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, List +from typing import Optional from database import db, DB_PATH from auth import get_current_user -logger = logging.getLogger(__name__) - router = APIRouter() _TZ = ZoneInfo("Europe/Berlin") @@ -89,11 +83,6 @@ 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 @@ -141,12 +130,6 @@ 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, @@ -155,7 +138,6 @@ 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, } @@ -1260,189 +1242,3 @@ 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), - } diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py deleted file mode 100644 index 2f53bae..0000000 --- a/backend/routes/invoices.py +++ /dev/null @@ -1,489 +0,0 @@ -"""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/backend/static/js/app.js b/backend/static/js/app.js index 30f3537..3555d46 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 = '966'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt +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 if (location.search.includes('_t=')) history.replaceState(null, '', '/'); diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index c519e48..8d8e780 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -27,7 +27,6 @@ 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' }, ]; // ------------------------------------------------------------------ @@ -98,7 +97,6 @@ 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); @@ -168,7 +166,6 @@ 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.'); @@ -3612,717 +3609,6 @@ 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 }; })(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 2293f58..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-v966'; +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 diff --git a/docker-compose.yml b/docker-compose.yml index 019c40d..772bb22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,6 @@ services: - "3010:8000" # DS-intern, NPM leitet banyaro.app weiter volumes: - ./data:/data # SQLite + Media persistent - - /volume1/scaninput:/scaninput env_file: - .env environment: