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
870 lines
34 KiB
Python
870 lines
34 KiB
Python
"""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 1–4 sein.")
|
||
month_start = (q - 1) * 3 + 1
|
||
month_end = month_start + 2
|
||
from_date = f"{year}-{month_start:02d}-01"
|
||
import calendar
|
||
last_day = calendar.monthrange(year, month_end)[1]
|
||
to_date = f"{year}-{month_end:02d}-{last_day:02d}"
|
||
|
||
labels = {1: "01.01.", 2: "01.04.", 3: "01.07.", 4: "01.10."}
|
||
ends = {1: "31.03.", 2: "30.06.", 3: "30.09.", 4: "31.12."}
|
||
period = f"Q{q} {year} ({labels[q]} - {ends[q]})"
|
||
|
||
with db() as conn:
|
||
# 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
|