banyaro/backend/routes/invoices.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
PYDANTIC max_length (38 Routen, ~400 Field-Constraints):
Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.).
Pragmatische Limits:
- Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000
- Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100
- Hund-Name/Rasse: 80 · Hund-Bio: 2000

Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py,
notes.py, auth.py, profile.py. Manuelle len()-Checks in profile,
chat, ki entfernt (jetzt durch Field abgedeckt).

PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail):
- test_security.py: require_owner (Places GET/PATCH/DELETE mit
  Fremduser → 403), JWT-Blacklist (Logout invalidiert Token),
  Login-Lockout (5 Fehlversuche → 429 + Retry-After Header)
- test_race.py: Invoice-Counter (20 parallele Threads, alle unique),
  Founder-Number (atomare Vergabe, voll bei 100)
- test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text
  50k → 422 (verifiziert Pydantic max_length-Sweep)

A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast):
- #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44
- dog-profile Wrapped-Slider Prev/Next 40→44
- forum-Lightbox Close 40→44
- --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS)
- --c-text-muted Dark:  #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS)
- Branding-Farben unangetastet
2026-05-27 13:40:30 +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, Field
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 = Field(..., max_length=500)
quantity: float = 1.0
unit_price: float
class InvoiceCreate(BaseModel):
user_id: Optional[int] = None
recipient_name: str = Field(..., max_length=200)
recipient_email: str = Field(..., max_length=254)
recipient_address: Optional[str] = Field(None, max_length=500)
items: List[InvoiceItem]
discount_pct: Optional[float] = 0.0
service_period: Optional[str] = Field(None, max_length=200)
notes: Optional[str] = Field(None, max_length=5000)
class PayBody(BaseModel):
paid_at: str = Field(..., max_length=32)
paid_amount: float
notes: Optional[str] = Field(None, max_length=2000)
class CancelBody(BaseModel):
reason: str = Field(..., min_length=3, max_length=1000)
# ------------------------------------------------------------------
# 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