Compare commits

..

No commits in common. "fa513be7f53e4c4849f7c164a2a2ec7d4a7cb154" and "c032b9a3fbb25a4c81e0578688d5fabf735e89bd" have entirely different histories.

9 changed files with 15 additions and 1493 deletions

View file

@ -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."""

View file

@ -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 <noreply@banyaro.app>" 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:

View file

@ -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():

View file

@ -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 14 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 14 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"<pre style='font-family:monospace'>{body_stb}</pre>",
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"<pre style='font-family:monospace'>{body_stb}</pre>",
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 <addr>" 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"<pre style='font-family:monospace'>{body_rene}</pre>",
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),
}

View file

@ -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 14 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"""
<p style="margin:0 0 16px">Hallo <b>{invoice['recipient_name']}</b>,</p>
<p style="margin:0 0 16px">
anbei erhalten Sie Ihre Rechnung <b>{invoice['invoice_number']}</b>
über <b>{invoice['amount_gross']:.2f} EUR</b>.
</p>
<p style="margin:0 0 8px">Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.</p>
<p style="margin:0;font-size:13px;color:#888">Verwendungszweck: {invoice['invoice_number']}</p>
"""
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)

View file

@ -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, '', '/');

View file

@ -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 = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);flex-wrap:wrap;gap:var(--space-2)">
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-sm ${_subView === 'liste' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="liste">
${UI.icon('list-bullets')} Rechnungen
</button>
<button class="btn btn-sm ${_subView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="cashflow">
${UI.icon('chart-bar')} Cashflow
</button>
</div>
${_subView === 'liste' ? `
<button class="btn btn-sm btn-secondary" id="adm-inv-new">
${UI.icon('plus')} Neue Rechnung
</button>` : ''}
</div>
<div id="adm-inv-content">
<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade</div>
</div>
`;
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 `<span style="display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;
background:${bg};color:${color};border:1px solid ${border}">${label}</span>`;
};
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(`<button class="btn btn-sm btn-primary adm-inv-send" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}" title="Senden">
${UI.icon('paper-plane-tilt')} Senden
</button>`);
}
if (inv.status === 'sent') {
actions.push(`<button class="btn btn-sm btn-secondary adm-inv-pay" data-id="${inv.id}" data-amount="${inv.amount_gross}" title="Als bezahlt markieren">
${UI.icon('check-circle')} Bezahlt
</button>`);
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-cancel" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}"
style="color:var(--c-danger)" title="Stornieren">
${UI.icon('x-circle')} Storno
</button>`);
}
if (inv.status === 'paid' || inv.status === 'cancelled') {
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-detail" data-id="${inv.id}" title="Details">
${UI.icon('eye')} Details
</button>`);
}
return `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td" style="font-weight:600;font-family:monospace;font-size:var(--text-xs)">
${_esc(inv.invoice_number)}
</td>
<td class="adm-td">
<div style="font-weight:500">${_esc(inv.recipient_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(inv.recipient_email || '')}</div>
</td>
<td class="adm-td" style="text-align:right;font-weight:700;white-space:nowrap">
${_fmtEur(inv.amount_gross)}
</td>
<td class="adm-td">${_statusBadge(inv.status)}</td>
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap">
${_fmtDate(inv.created_at)}
</td>
<td class="adm-td" style="white-space:nowrap">
<div style="display:flex;gap:var(--space-1);justify-content:flex-end">${actions.join('')}</div>
</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="card adm-table-card">
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Nummer</th>
<th class="adm-th">Empfänger</th>
<th class="adm-th" style="text-align:right">Betrag</th>
<th class="adm-th">Status</th>
<th class="adm-th">Erstellt</th>
<th class="adm-th"></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
`;
// 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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<!-- Empfänger -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Empfänger Name *</label>
<input class="form-control" name="recipient_name" type="text" required placeholder="Max Muster">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">E-Mail</label>
<input class="form-control" name="recipient_email" type="email" placeholder="max@example.com">
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Adresse <span style="color:var(--c-text-muted)">(optional)</span></label>
<textarea class="form-control" name="recipient_address" rows="2"
placeholder="Musterstr. 1&#10;12345 Berlin"
style="resize:vertical;font-family:inherit"></textarea>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Leistungszeitraum <span style="color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" name="service_period" type="text"
placeholder="01.01.2026 31.12.2026">
</div>
<!-- Positionen -->
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs);margin:0">Positionen *</label>
<button type="button" id="${id}-add-item"
style="font-size:var(--text-xs);color:var(--c-primary);background:none;border:none;cursor:pointer;padding:0;font-weight:600">
+ Position hinzufügen
</button>
</div>
<div id="${id}-items" style="display:flex;flex-direction:column;gap:var(--space-2)">
<!-- Items werden dynamisch eingefügt -->
</div>
</div>
<!-- Rabatt -->
<div style="display:grid;grid-template-columns:auto 1fr;gap:var(--space-3);align-items:center">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs);margin:0;white-space:nowrap">Rabatt %</label>
<input class="form-control" name="discount_pct" type="number" min="0" max="100" value="0"
style="width:80px" id="${id}-discount">
</div>
<!-- Live-Vorschau -->
<div id="${id}-preview" style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-size:var(--text-xs);text-align:right">
<span style="color:var(--c-text-muted)">Netto: </span>
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Notizen <span style="color:var(--c-text-muted)">(optional)</span></label>
<textarea class="form-control" name="notes" rows="2"
style="resize:vertical;font-family:inherit"
placeholder="Interne Notiz / Zahlungshinweis"></textarea>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('receipt')} Rechnung erstellen</button>
`,
});
// 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 = `
<input class="form-control inv-item-desc" type="text" placeholder="Beschreibung *"
value="${_esc(desc)}" style="font-size:var(--text-sm)">
<input class="form-control inv-item-qty" type="number" min="1" value="${qty}"
style="font-size:var(--text-sm);text-align:right" title="Menge">
<input class="form-control inv-item-price" type="number" min="0" step="0.01" value="${price.toFixed(2)}"
style="font-size:var(--text-sm);text-align:right" title="Einzelpreis €">
<button type="button" class="btn btn-sm btn-ghost inv-item-remove"
style="color:var(--c-danger);padding:4px 8px;flex-shrink:0" title="Entfernen">
${UI.icon('x')}
</button>
`;
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 = `
<span style="color:var(--c-text-muted)">Netto: </span>
<strong>${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} </strong>
${disc > 0 ? `&nbsp;·&nbsp;<span style="color:var(--c-danger)">-${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)</span>` : ''}
&nbsp;·&nbsp;<span style="color:var(--c-success);font-weight:700">Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} </span>
`;
}
// 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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Zahlungsdatum *</label>
<input class="form-control" name="paid_at" type="date" value="${today}" required>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Betrag () *</label>
<input class="form-control" name="paid_amount" type="number" min="0" step="0.01"
value="${defaultAmount.toFixed(2)}" required>
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('check-circle')} Als bezahlt markieren</button>
`,
});
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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Rechnung <strong>${_esc(invoiceNum)}</strong> stornieren.
</p>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Stornierungsgrund *</label>
<input class="form-control" name="reason" type="text" required
placeholder="z. B. Kundenwunsch, Doppelabrechnung…">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit"
style="background:var(--c-danger);border-color:var(--c-danger)">
${UI.icon('x-circle')} Rechnung stornieren
</button>
`,
});
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 => `
<tr>
<td style="padding:6px 8px">${_esc(item.description)}</td>
<td style="padding:6px 8px;text-align:right">${item.quantity}</td>
<td style="padding:6px 8px;text-align:right">${_fmtEur(item.unit_price)}</td>
<td style="padding:6px 8px;text-align:right;font-weight:600">${_fmtEur(item.total)}</td>
</tr>
`).join('');
UI.modal.open({
title: `${UI.icon('receipt')} ${_esc(inv.invoice_number)}`,
body: `
<div style="display:flex;flex-direction:column;gap:var(--space-3);font-size:var(--text-sm)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Empfänger</div>
<div style="font-weight:600">${_esc(inv.recipient_name)}</div>
${inv.recipient_email ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(inv.recipient_email)}</div>` : ''}
${inv.recipient_address ? `<div style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:pre-line;margin-top:2px">${_esc(inv.recipient_address)}</div>` : ''}
</div>
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Status</div>
<div style="font-weight:700;color:${statusColors[inv.status] || 'inherit'}">${statusLabels[inv.status] || inv.status}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Erstellt: ${_fmtDate(inv.created_at)}<br>
${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}<br>` : ''}
${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}<br>` : ''}
</div>
</div>
</div>
${inv.service_period ? `
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Leistungszeitraum</div>
<div>${_esc(inv.service_period)}</div>
</div>` : ''}
<!-- Positionen -->
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Positionen</div>
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border);color:var(--c-text-muted)">
<th style="text-align:left;padding:4px 8px">Beschreibung</th>
<th style="text-align:right;padding:4px 8px">Menge</th>
<th style="text-align:right;padding:4px 8px">Preis</th>
<th style="text-align:right;padding:4px 8px">Gesamt</th>
</tr>
</thead>
<tbody>${itemsHtml}</tbody>
<tfoot>
<tr style="border-top:2px solid var(--c-border)">
<td colspan="3" style="padding:6px 8px;text-align:right;font-weight:600">Gesamt (brutto)</td>
<td style="padding:6px 8px;text-align:right;font-weight:700;color:var(--c-primary)">${_fmtEur(inv.amount_gross)}</td>
</tr>
</tfoot>
</table>
</div>
${inv.notes ? `
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Notizen</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-xs);white-space:pre-wrap">${_esc(inv.notes)}</div>
</div>` : ''}
</div>
`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
}
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]) => `
<div class="card" style="padding:var(--space-3);text-align:center">
<div style="font-size:var(--text-xl);font-weight:800;color:${statusColors[s] || 'var(--c-text)'}">${n}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${statusLabels[s] || s}</div>
</div>`).join('');
const monthRows = (cf.monthly || []).map((m, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td">${_esc(m.month)}</td>
<td class="adm-td" style="text-align:right">${m.count}</td>
<td class="adm-td" style="text-align:right;font-weight:600">${_fmtEur(m.revenue)}</td>
</tr>`).join('');
// Quartalsbericht-Download
const currentYear = new Date().getFullYear();
const years = [currentYear, currentYear - 1].map(y => `<option value="${y}">${y}</option>`).join('');
el.innerHTML = `
<!-- Übersichtskacheln -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--space-3);margin-bottom:var(--space-4)">
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success,#16a34a)">${_fmtEur(cf.total_paid)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Einnahmen (bezahlt)</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${_fmtEur(cf.total_outstanding)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Offene Forderungen</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-text)">${_fmtEur(cf.total_year)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Jahresumsatz gesamt</div>
</div>
${countKacheln}
</div>
<!-- Monatliche Tabelle -->
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
border-bottom:1px solid var(--c-border)">Monatliche Übersicht</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Monat</th>
<th class="adm-th" style="text-align:right">Rechnungen</th>
<th class="adm-th" style="text-align:right">Umsatz</th>
</tr>
</thead>
<tbody>
${monthRows || `<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Keine Daten</td></tr>`}
</tbody>
</table>
</div>
</div>
<!-- Quartalsbericht -->
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
${UI.icon('file-csv')} Quartalsbericht herunterladen
</div>
<div style="display:flex;gap:var(--space-3);align-items:flex-end;flex-wrap:wrap">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Jahr</label>
<select id="adm-inv-year" class="form-control" style="width:auto">${years}</select>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Quartal</label>
<select id="adm-inv-quarter" class="form-control" style="width:auto">
<option value="1">Q1 (JanMär)</option>
<option value="2">Q2 (AprJun)</option>
<option value="3">Q3 (JulSep)</option>
<option value="4">Q4 (OktDez)</option>
</select>
</div>
<button class="btn btn-secondary btn-sm" id="adm-inv-csv">
${UI.icon('download-simple')} CSV herunterladen
</button>
<button class="btn btn-ghost btn-sm" id="adm-inv-preview-q">
${UI.icon('eye')} Vorschau
</button>
</div>
<div id="adm-inv-q-result" style="margin-top:var(--space-3)"></div>
</div>
`;
// 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 = '<div style="color:var(--c-text-muted);font-size:var(--text-xs)">Lade…</div>';
try {
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
if (!data.invoices?.length) {
resultEl.innerHTML = `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Keine Rechnungen in ${data.period || `Q${q} ${year}`}.</div>`;
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) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-family:monospace;font-size:var(--text-xs)">${_esc(inv.invoice_number)}</td>
<td class="adm-td">${_esc(inv.recipient_name)}</td>
<td class="adm-td" style="text-align:right;font-weight:600">${_fmtE(inv.amount_gross)}</td>
<td class="adm-td">${sL[inv.status]||inv.status}</td>
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtD(inv.created_at)}</td>
</tr>`).join('');
resultEl.innerHTML = `
<div style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);margin-bottom:var(--space-2)">
${_esc(data.period || `Q${q} ${year}`)} ${data.count} Rechnung(en) · Brutto: ${_fmtE(data.total_gross)}
</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead><tr style="background:var(--c-surface-2)">
<th class="adm-th">Nummer</th><th class="adm-th">Empfänger</th>
<th class="adm-th" style="text-align:right">Betrag</th><th class="adm-th">Status</th>
<th class="adm-th">Erstellt</th>
</tr></thead>
<tbody>${rows2}</tbody>
<tfoot><tr style="border-top:2px solid var(--c-border)">
<td colspan="2" class="adm-td" style="font-weight:600">Gesamt</td>
<td class="adm-td" style="text-align:right;font-weight:700;color:var(--c-primary)">${_fmtE(data.total_gross)}</td>
<td colspan="2" class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted)">
Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)}
</td>
</tr></tfoot>
</table>
</div>`;
} catch (e) {
resultEl.innerHTML = `<div style="color:var(--c-danger);font-size:var(--text-xs)">Fehler: ${_esc(e.message)}</div>`;
}
});
}
return { init, refresh, onDogChange };
})();

View file

@ -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

View file

@ -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: