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..3d79bb8 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) # ------------------------------------------------------------------ @@ -406,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/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), + } 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/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/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 }; })(); 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 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: