banyaro/backend/routes/invoices.py
rene 9394bab1fb Big Sweep: Security + Race-Conditions + Tests + DSGVO + A11y, SW by-v1095
SECURITY (auth.py, routes/auth.py, database.py, main.py)
- JWT bekommt jti; Logout trägt in neue jwt_blacklist-Tabelle ein,
  decode_token() prüft → server-side Invalidierung
- JWT-Expiry default 30 → 7 Tage (ENV JWT_EXPIRY_DAYS überschreibt)
- Sliding-Refresh-Middleware: erneuert Cookie wenn >50% verbraucht
  (Schwelle via JWT_REFRESH_FRACTION, Default 2)
- Login-Lockout in DB-Tabelle login_attempts (5 Versuche / 15 Min,
  überlebt Container-Restart) — alte In-Memory-Lockouts ersetzt
- SMTP-Versand: alle 'except: pass' durch logger.exception ersetzt;
  Fehlversuche landen in failed_emails-Tabelle für späteres Retry
- Referral-Counter Race gefixt: UPDATE partner_codes SET uses=uses+1
  ... WHERE uses<max_uses RETURNING — atomar statt SELECT+UPDATE

RACE CONDITIONS (routes/invoices.py, database.py)
- Neue invoice_counters-Tabelle für atomare Nummernvergabe
- _next_invoice_number nutzt BEGIN IMMEDIATE + atomares UPDATE
- Funktioniert für RG- und ST-Prefixe (Stornorechnungen)
- Race-Test verifiziert (5 Threads × 20 Calls = 100 eindeutige Nummern)

VERSION + TESTS + ERROR-DIGEST (VERSION, Makefile, tests/, scheduler.py)
- Neue VERSION-Datei (Single Source of Truth) — main.py liest beim
  Startup
- Makefile-Target 'make bump' propagiert in sw.js, app.js, index.html
- Makefile-Target 'make test' setzt venv auf, läuft pytest
- 19 Smoke-Tests in tests/ (health, auth, diary, invoice) — alle grün
- Scheduler: täglicher _job_error_digest um 06:30 → schickt Error-
  Zusammenfassung an ADMIN_EMAIL (still wenn keine Errors)

DSGVO + A11Y + ERSTE-HILFE
- landing.html: 'HTML und ODS' → 'JSON' (tatsächlich implementiert)
- datenschutz.js: Sektion Account-Löschung erweitert (sofort gelöscht /
  anonymisiert / 10 Jahre für Rechnungen)
- erste-hilfe.js: prominentes Warning-Banner oben (ersetzt keine
  Tierarzt-Beratung); Notfallnummern gruppiert nach Land, TODO-Platz-
  halter für AT-Uni-Klinik, CH Tox Info Suisse, CH Tierspital Zürich
- ui.js Modal: ESC schließt, Focus-Trap, Auto-Focus erstes Element,
  Restore Focus auf vorigen Caller
- impressum.js Kontaktformular: Labels mit for=cf-name etc.

NEUE DB-TABELLEN (idempotent via CREATE TABLE IF NOT EXISTS)
- jwt_blacklist, login_attempts, failed_emails, invoice_counters

NEUE ENV-VARS
- JWT_REFRESH_FRACTION (Default 2)
- JWT_EXPIRY_DAYS Default geändert (30 → 7)
2026-05-26 20:12:01 +02:00

870 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
notes: Optional[str] = None
class CancelBody(BaseModel):
reason: str
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def _next_invoice_number(conn, prefix="RG"):
"""Vergibt atomar die naechste Rechnungsnummer fuer (prefix, year).
Race-frei dank dedizierter Counter-Tabelle 'invoice_counters' und
BEGIN IMMEDIATE — gleichzeitige Aufrufe von zwei Admins koennen nicht
dieselbe Nummer ziehen. SQLite serialisiert die Writer; der zweite
wartet bis busy_timeout.
Beim ersten Aufruf fuer (prefix, year) wird die Counter-Row angelegt;
dabei wird der aktuelle Stand aus der invoices-Tabelle uebernommen
(Backfill fuer bestehende Bestaende vor Einfuehrung des Counters).
"""
year = datetime.now().year
# Falls noch keine Transaktion offen ist: BEGIN IMMEDIATE,
# damit der naechste Writer serialisiert wird.
if not conn.in_transaction:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT next_num FROM invoice_counters WHERE prefix=? AND year=?",
(prefix, year)
).fetchone()
if row is None:
# Counter fehlt → Backfill aus invoices-Tabelle (max. bisheriger
# Nummer + 1), damit ein nachtraeglich eingefuehrter Counter
# nicht bei 1 startet und Kollisionen erzeugt.
last = conn.execute(
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? "
"ORDER BY id DESC LIMIT 1",
(f"{prefix}-{year}-%",)
).fetchone()
# Auch cancellation_number kann den 'ST'-Prefix tragen
last_st = conn.execute(
"SELECT cancellation_number FROM invoices "
"WHERE cancellation_number LIKE ? ORDER BY id DESC LIMIT 1",
(f"{prefix}-{year}-%",)
).fetchone()
existing_max = 0
for r in (last, last_st):
if r and r[0]:
try:
existing_max = max(existing_max, int(r[0].split("-")[-1]))
except (ValueError, IndexError):
pass
n = existing_max + 1
conn.execute(
"INSERT INTO invoice_counters (prefix, year, next_num) VALUES (?,?,?)",
(prefix, year, n + 1)
)
else:
n = row[0]
conn.execute(
"UPDATE invoice_counters SET next_num = next_num + 1 "
"WHERE prefix=? AND year=?",
(prefix, year)
)
return f"{prefix}-{year}-{n:04d}"
def _generate_pdf(invoice, items) -> bytes:
from fpdf import FPDF
from datetime import datetime, timedelta
KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true"
STEUERNUMMER = os.getenv("STEUERNUMMER", "")
INHABER = os.getenv("RECHNUNG_INHABER", "Rene Degelmann")
FIRMA = os.getenv("RECHNUNG_GESCHAEFTSNAME", "Ban Yaro")
STRASSE = os.getenv("RECHNUNG_STRASSE", "")
PLZ_ORT = os.getenv("RECHNUNG_PLZ_ORT", "")
EMAIL = os.getenv("RECHNUNG_EMAIL", "hallo@banyaro.app")
WEBSITE = os.getenv("RECHNUNG_WEBSITE", "banyaro.app")
IBAN = os.getenv("RECHNUNG_IBAN", "")
BIC = os.getenv("RECHNUNG_BIC", "")
BANKNAME = os.getenv("RECHNUNG_BANK", "")
OR = (230, 126, 34)
DK = (30, 30, 30)
GY = (130, 130, 130)
LG = (245, 245, 245)
WH = (255, 255, 255)
def _s(text) -> str:
"""Nicht-Latin1-Zeichen ersetzen bevor sie an fpdf Helvetica übergeben werden."""
if not text:
return ""
return (str(text)
.replace("", "-").replace("", "-") # En/Em-Dash
.replace("", "'").replace("", "'") # Typogr. Anf.zeichen
.replace("", '"').replace("", '"')
.replace("", "...").replace("·", ".")
.replace("", "EUR") # € falls doch
)
def eur(v: float) -> str:
s = f"{v:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
return f"{s} EUR"
def fdate(iso: str) -> str:
try:
y, m, d = (iso or "")[:10].split("-")
return f"{d}.{m}.{y}"
except Exception:
return (iso or "")[:10]
try:
due_date = (datetime.fromisoformat(invoice["created_at"][:10]) + timedelta(days=14)).strftime("%d.%m.%Y")
except Exception:
due_date = ""
icon_path = os.path.join(os.path.dirname(__file__), "..", "static", "icons", "icon-192.png")
icon_path = os.path.abspath(icon_path)
pdf = FPDF()
pdf.add_page()
pdf.set_margins(20, 0, 20)
pdf.set_auto_page_break(auto=True, margin=22)
W = 170
# ── Header-Balken (volle Breite, 16mm) ───────────────────────
pdf.set_fill_color(*OR)
pdf.rect(0, 0, 210, 16, "F")
# App-Icon links im Balken
if os.path.exists(icon_path):
pdf.image(icon_path, x=18, y=1, w=14, h=14)
# "Ban Yaro" in Weiss rechts im Balken
pdf.set_xy(20, 1)
pdf.set_font("Helvetica", "B", 20)
pdf.set_text_color(*WH)
pdf.cell(W, 14, FIRMA, align="R")
# ── Absenderadresse rechts (unterhalb Balken) ─────────────────
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*GY)
y_addr = 19
for line in filter(None, [INHABER, STRASSE, PLZ_ORT, EMAIL, WEBSITE]):
pdf.set_xy(20, y_addr)
pdf.cell(W, 4, line, align="R")
y_addr += 4.2
# ── Absenderzeile + Trennstrich (DIN 5008) ────────────────────
sender_ref = " · ".join(filter(None, [FIRMA, INHABER, STRASSE, PLZ_ORT]))
pdf.set_xy(20, 46)
pdf.set_font("Helvetica", "", 6.5)
pdf.set_text_color(*GY)
pdf.cell(85, 3.5, sender_ref)
pdf.set_draw_color(*GY)
pdf.set_line_width(0.15)
pdf.line(20, 50, 105, 50)
# ── Empfänger links ───────────────────────────────────────────
pdf.set_xy(20, 52)
pdf.set_font("Helvetica", "B", 10)
pdf.set_text_color(*DK)
pdf.cell(85, 5.5, _s(invoice["recipient_name"]), new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
if invoice.get("recipient_address"):
for line in str(invoice["recipient_address"]).split("\n"):
if line.strip():
pdf.set_x(20)
pdf.cell(85, 5, _s(line.strip()), new_x="LMARGIN", new_y="NEXT")
pdf.set_x(20)
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*GY)
pdf.cell(85, 5, _s(invoice["recipient_email"]))
# ── Info-Block rechts ─────────────────────────────────────────
info_rows = [
("Rechnungsnummer", invoice["invoice_number"]),
("Datum", fdate(invoice.get("created_at", ""))),
("Fällig bis", due_date),
]
if invoice.get("service_period"):
info_rows.append(("Leistungszeitraum", _s(invoice["service_period"])))
y_info = 52
for lbl, val in info_rows:
pdf.set_xy(110, y_info)
pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*GY)
pdf.cell(35, 5.5, lbl + ":")
pdf.set_font("Helvetica", "B", 8.5)
pdf.set_text_color(*DK)
pdf.cell(25, 5.5, val)
y_info += 6
# ── Betreff ───────────────────────────────────────────────────
pdf.set_xy(20, 90)
pdf.set_font("Helvetica", "B", 13)
pdf.set_text_color(*DK)
pdf.cell(W, 7, f"Rechnung {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT")
pdf.set_draw_color(*OR)
pdf.set_line_width(0.6)
pdf.line(20, pdf.get_y(), 190, pdf.get_y())
pdf.ln(4)
# ── Positionen-Tabelle ────────────────────────────────────────
CW = (90, 18, 32, 30)
pdf.set_fill_color(*OR)
pdf.set_text_color(*WH)
pdf.set_font("Helvetica", "B", 9)
pdf.cell(CW[0], 7, " Beschreibung", fill=True)
pdf.cell(CW[1], 7, "Menge", fill=True, align="C")
pdf.cell(CW[2], 7, "Einzelpreis", fill=True, align="R")
pdf.cell(CW[3], 7, "Gesamt", fill=True, align="R", new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(*DK)
pdf.set_font("Helvetica", "", 9)
pdf.set_line_width(0.2)
pdf.set_draw_color(200, 200, 200)
for i, item in enumerate(items):
pdf.set_fill_color(*(LG if i % 2 == 0 else WH))
qty = f"{item['quantity']:.2f}".rstrip("0").rstrip(".")
pdf.cell(CW[0], 7, f" {_s(str(item['description']))[:64]}", border="B", fill=True)
pdf.cell(CW[1], 7, qty, border="B", fill=True, align="C")
pdf.cell(CW[2], 7, eur(item["unit_price"]), border="B", fill=True, align="R")
pdf.cell(CW[3], 7, eur(item["total"]), border="B", fill=True, align="R",
new_x="LMARGIN", new_y="NEXT")
pdf.ln(4)
# ── Summenblock ───────────────────────────────────────────────
def srow(lbl, val, bold=False, txt_color=None, bg=None):
pdf.set_x(110)
pdf.set_fill_color(*(bg or WH))
pdf.set_text_color(*(txt_color or DK))
pdf.set_font("Helvetica", "B" if bold else "", 10 if bold else 9)
pdf.cell(50, 6, lbl, align="R", fill=bool(bg))
pdf.cell(30, 6, val, align="R", fill=bool(bg), new_x="LMARGIN", new_y="NEXT")
srow("Nettobetrag:", eur(invoice["amount_net"]))
if invoice.get("discount_pct") and invoice["discount_pct"] > 0:
srow(f"Rabatt ({invoice['discount_pct']:.0f}%):", f"- {eur(invoice['discount_amount'])}", txt_color=OR)
srow("Nach Rabatt:", eur(invoice["amount_after_discount"]))
if not KLEINUNTERNEHMER and invoice.get("tax_rate", 0) > 0:
srow(f"MwSt. {invoice['tax_rate']:.0f}%:", eur(invoice["tax_amount"]))
pdf.set_draw_color(*OR)
pdf.set_line_width(0.5)
pdf.line(110, pdf.get_y(), 190, pdf.get_y())
pdf.ln(1)
srow("Gesamtbetrag:", eur(invoice["amount_gross"]), bold=True, bg=LG)
pdf.ln(3)
# ── §19-Hinweis ───────────────────────────────────────────────
if KLEINUNTERNEHMER:
pdf.set_x(20)
pdf.set_font("Helvetica", "I", 8.5)
pdf.set_text_color(*GY)
pdf.multi_cell(W, 5, _s("Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet."))
# ── Zahlungsinfo-Box ──────────────────────────────────────────
pdf.ln(5)
y_box = pdf.get_y()
pdf.set_x(24)
pdf.set_font("Helvetica", "B", 9)
pdf.set_text_color(*OR)
pdf.cell(W - 4, 6, "Zahlungsinformationen", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(*DK)
pay_rows = []
if due_date: pay_rows.append(("Zahlbar bis:", due_date))
if IBAN: pay_rows.append(("IBAN:", IBAN))
if BIC: pay_rows.append(("BIC:", BIC))
if BANKNAME: pay_rows.append(("Bank:", BANKNAME))
pay_rows.append( ("Verwendungszweck:", invoice["invoice_number"]))
for lbl, val in pay_rows:
pdf.set_x(24)
pdf.set_font("Helvetica", "", 9)
pdf.cell(45, 5.5, lbl)
pdf.set_font("Helvetica", "" if lbl == "Verwendungszweck:" else "B", 9)
pdf.cell(0, 5.5, val, new_x="LMARGIN", new_y="NEXT")
pdf.set_fill_color(*OR)
pdf.rect(20, y_box, 2, pdf.get_y() - y_box + 1, "F")
# ── Notizen ───────────────────────────────────────────────────
if invoice.get("notes"):
pdf.ln(4)
pdf.set_x(20)
pdf.set_font("Helvetica", "I", 9)
pdf.set_text_color(*GY)
pdf.multi_cell(W, 5, _s(str(invoice["notes"])))
# ── Footer (fixiert auf Seite 1, kein auto-break) ─────────────
pdf.set_auto_page_break(False)
pdf.set_y(277)
pdf.set_draw_color(*OR)
pdf.set_line_width(0.4)
pdf.line(20, pdf.get_y(), 190, pdf.get_y())
pdf.ln(1.5)
footer_parts = [FIRMA, INHABER]
if STEUERNUMMER:
footer_parts.append(f"Steuernr.: {STEUERNUMMER}")
if EMAIL:
footer_parts.append(EMAIL)
if WEBSITE:
footer_parts.append(WEBSITE)
pdf.set_font("Helvetica", "", 7.5)
pdf.set_text_color(*GY)
pdf.set_x(20)
pdf.cell(W, 4, " | ".join(footer_parts), align="C")
return bytes(pdf.output())
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)
logger.info(f"PDF gespeichert: {path} ({len(pdf_bytes)} 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(CASE WHEN status='paid'
THEN COALESCE(paid_amount, amount_gross)
ELSE amount_gross END) AS revenue,
COUNT(*) AS count
FROM invoices
WHERE status IN ('sent', 'paid')
GROUP BY month
ORDER BY month DESC
""").fetchall()
year = datetime.now().year
total_year = conn.execute(
"""SELECT COALESCE(SUM(CASE WHEN status='paid'
THEN COALESCE(paid_amount, amount_gross)
ELSE amount_gross END), 0)
FROM invoices WHERE status IN ('sent','paid') AND created_at LIKE ?""",
(f"{year}%",)
).fetchone()[0]
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:
# Alle Rechnungen außer Entwürfe im Quartal (nach Ausstellungsdatum)
rows = conn.execute(
"SELECT * FROM invoices WHERE status != 'draft' AND created_at >= ? AND created_at <= ? ORDER BY created_at ASC",
(from_date, to_date + "T23:59:59Z")
).fetchall()
# Stornorechnungen die im Quartal ausgestellt wurden (cancelled_at im Zeitraum,
# auch wenn die Originalrechnung außerhalb des Quartals liegt)
storno_rows = conn.execute(
"SELECT * FROM invoices WHERE status = 'cancelled' AND cancelled_at >= ? AND cancelled_at <= ?",
(from_date, to_date + "T23:59:59Z")
).fetchall()
# Buchungseinträge aufbauen
entries = []
# Originalrechnungen (paid, sent — mit positivem Betrag)
for r in rows:
d = _row_to_dict(r)
if d["status"] in ("paid", "sent"):
entries.append(d)
elif d["status"] == "cancelled":
# Originalrechnung erscheint mit positivem Betrag (wurde ausgestellt)
entries.append(d)
# Stornozeilen: negative Beträge, Datum = cancelled_at, Nummer = cancellation_number
storno_ids_already = {r["id"] for r in rows}
for r in storno_rows:
d = _row_to_dict(r)
storno_entry = {
"invoice_number": d["cancellation_number"] or f"ST-{d['invoice_number']}",
"recipient_name": d["recipient_name"],
"recipient_email": d["recipient_email"],
"created_at": d["cancelled_at"],
"service_period": d["service_period"],
"amount_net": -round(d["amount_net"], 2),
"tax_amount": -round(d.get("tax_amount") or 0, 2),
"amount_gross": -round(d["amount_gross"], 2),
"paid_amount": None,
"status": "storno",
"sent_at": None,
"paid_at": None,
"cancellation_number": d["cancellation_number"],
"notes": f"Storno zu {d['invoice_number']}",
}
entries.append(storno_entry)
# Wenn Original NICHT im Quartal aber Storno schon → Original trotzdem zeigen
if r["id"] not in storno_ids_already:
orig = _row_to_dict(r)
entries.append(orig)
# Nach Datum sortieren
entries.sort(key=lambda e: (e.get("created_at") or ""))
# Summen: alle Einträge — Storno (-) und Original (+) heben sich gegenseitig auf
# Für bezahlte Rechnungen den tatsächlich eingegangenen Betrag verwenden
def _effective_gross(e):
if e.get("status") == "paid" and e.get("paid_amount") is not None:
return e["paid_amount"]
return e.get("amount_gross") or 0
total_gross = sum(_effective_gross(e) for e in entries)
total_tax = sum(e.get("tax_amount") or 0 for e in entries)
return {
"period": period,
"invoices": entries,
"total_tax": round(total_tax, 2),
"total_gross": round(total_gross, 2),
"count": len(entries),
}
@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.patch("/{invoice_id}")
def update_invoice(invoice_id: int, data: InvoiceCreate, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
raise HTTPException(404, "Rechnung nicht gefunden.")
if row["status"] != "draft":
raise HTTPException(400, "Nur Entwürfe können bearbeitet werden.")
if not data.items:
raise HTTPException(400, "Mindestens eine Position erforderlich.")
KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true"
TAX_RATE = 0.0 if KLEINUNTERNEHMER else float(os.getenv("RECHNUNG_MWST", "19"))
amount_net = round(sum(i.quantity * i.unit_price for i in data.items), 2)
discount_pct = data.discount_pct or 0.0
discount_amount = round(amount_net * discount_pct / 100, 2)
amount_after_discount = round(amount_net - discount_amount, 2)
tax_amount = round(amount_after_discount * TAX_RATE / 100, 2)
amount_gross = round(amount_after_discount + tax_amount, 2)
description = data.items[0].description if len(data.items) == 1 else f"{len(data.items)} Positionen"
conn.execute("""
UPDATE invoices SET
recipient_name=?, recipient_email=?, recipient_address=?,
description=?, service_period=?,
amount_net=?, discount_pct=?, discount_amount=?,
amount_after_discount=?, tax_rate=?, tax_amount=?, amount_gross=?,
notes=?
WHERE id=?
""", (
data.recipient_name, data.recipient_email, data.recipient_address,
description, data.service_period,
amount_net, discount_pct, discount_amount,
amount_after_discount, TAX_RATE, tax_amount, amount_gross,
data.notes, invoice_id,
))
conn.execute("DELETE FROM invoice_items WHERE invoice_id=?", (invoice_id,))
for item in data.items:
total = round(item.quantity * item.unit_price, 2)
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)",
(invoice_id, item.description, item.quantity, item.unit_price, total)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
items = _fetch_items(conn, invoice_id)
result = _row_to_dict(row)
result["items"] = items
return result
@router.post("", status_code=201)
def create_invoice(data: InvoiceCreate, admin=Depends(require_admin)):
if not data.items:
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.")
if row["status"] == "paid":
raise HTTPException(400, "Bezahlte Rechnung kann nicht erneut gesendet werden.")
items = _fetch_items(conn, invoice_id)
invoice = _row_to_dict(row)
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.")
if data.notes:
conn.execute(
"UPDATE invoices SET status='paid', paid_at=?, paid_amount=?, notes=? WHERE id=?",
(data.paid_at, data.paid_amount, data.notes, invoice_id)
)
else:
conn.execute(
"UPDATE invoices SET status='paid', paid_at=?, paid_amount=? WHERE id=?",
(data.paid_at, data.paid_amount, invoice_id)
)
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
return _row_to_dict(row)
@router.post("/{invoice_id}/cancel")
async def cancel_invoice(invoice_id: int, data: CancelBody, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone()
if not row:
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()
items = _fetch_items(conn, invoice_id)
invoice = _row_to_dict(row)
# Storno-PDF: invoice-Dict als Stornobeleg aufbereiten
orig_date = (invoice.get("created_at") or "")[:10]
try:
from datetime import datetime as _dt
y, m, d = orig_date.split("-")
orig_date_de = f"{d}.{m}.{y}"
except Exception:
orig_date_de = orig_date
storno_invoice = dict(invoice)
storno_invoice["invoice_number"] = cancellation_number
storno_invoice["notes"] = (
f"Stornorechnung zu Rechnung {invoice['invoice_number']} vom {orig_date_de}\n"
f"Grund: {data.reason}"
)
storno_invoice["amount_net"] = -invoice["amount_net"]
storno_invoice["discount_amount"] = -invoice.get("discount_amount", 0)
storno_invoice["amount_after_discount"] = -invoice["amount_after_discount"]
storno_invoice["tax_amount"] = -invoice.get("tax_amount", 0)
storno_invoice["amount_gross"] = -invoice["amount_gross"]
for item in items:
item["unit_price"] = -item["unit_price"]
item["total"] = -item["total"]
try:
pdf_bytes = _generate_pdf(storno_invoice, items)
except Exception as e:
logger.error(f"Storno-PDF fehlgeschlagen: {e}")
return _row_to_dict(row)
filename = f"{cancellation_number}_banyaro.pdf"
try:
await _save_to_paperless(pdf_bytes, cancellation_number, filename)
except Exception as e:
logger.warning(f"Storno Paperless fehlgeschlagen: {e}")
# Mail an Kunden
try:
body_html = mailer.email_html(f"""
<p style="margin:0 0 16px">Hallo <b>{invoice['recipient_name']}</b>,</p>
<p style="margin:0 0 16px">
Ihre Rechnung <b>{invoice['invoice_number']}</b> wurde storniert
(Stornonummer: <b>{cancellation_number}</b>).
</p>
<p style="margin:0 0 8px;color:#666;font-size:13px">Grund: {data.reason}</p>
<p style="margin:0;color:#666;font-size:13px">
Das Stornodokument liegt diesem Schreiben bei.
</p>
""")
plain = (
f"Hallo {invoice['recipient_name']},\n\n"
f"Ihre Rechnung {invoice['invoice_number']} wurde storniert "
f"(Stornonummer: {cancellation_number}).\n"
f"Grund: {data.reason}\n"
)
await mailer.send_email(
to=invoice["recipient_email"],
subject=f"Stornierung Rechnung {invoice['invoice_number']} — Ban Yaro",
html=body_html,
plain=plain,
attachments=[{"filename": filename, "content": pdf_bytes, "content_type": "application/pdf"}],
)
except Exception as e:
logger.error(f"Storno-Mail fehlgeschlagen: {e}")
return invoice