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

Hallo {invoice['recipient_name']},

anbei erhalten Sie Ihre Rechnung {invoice['invoice_number']} über {invoice['amount_gross']:.2f} EUR.

Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.

Verwendungszweck: {invoice['invoice_number']}

""" html = mailer.email_html(body_html) plain = ( f"Hallo {invoice['recipient_name']},\n\n" f"anbei Ihre Rechnung {invoice['invoice_number']} über {invoice['amount_gross']:.2f} EUR.\n\n" f"Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.\n" f"Verwendungszweck: {invoice['invoice_number']}\n" ) attachments = [{ "filename": filename, "content": pdf_bytes, "content_type": "application/pdf", }] try: await mailer.send_email( to=invoice["recipient_email"], subject=f"Ihre Rechnung {invoice['invoice_number']} von Ban Yaro", html=html, plain=plain, attachments=attachments, ) except Exception as e: logger.error(f"Mail-Versand fehlgeschlagen: {e}") raise HTTPException(500, f"Mail-Versand fehlgeschlagen: {e}") now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z") with db() as conn: conn.execute( "UPDATE invoices SET status='sent', sent_at=? WHERE id=?", (now, invoice_id) ) row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() return _row_to_dict(row) @router.get("/{invoice_id}/pdf") def download_pdf(invoice_id: int, admin=Depends(require_admin)): with db() as conn: row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() if not row: raise HTTPException(404, "Rechnung nicht gefunden.") items = _fetch_items(conn, invoice_id) invoice = _row_to_dict(row) pdf_bytes = _generate_pdf(invoice, items) filename = f"{invoice['invoice_number']}_banyaro.pdf" return Response( content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/{invoice_id}/pay") def pay_invoice(invoice_id: int, data: PayBody, admin=Depends(require_admin)): with db() as conn: row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() if not row: raise HTTPException(404, "Rechnung nicht gefunden.") if row["status"] == "cancelled": raise HTTPException(400, "Stornierte Rechnung kann nicht als bezahlt markiert werden.") 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"""

Hallo {invoice['recipient_name']},

Ihre Rechnung {invoice['invoice_number']} wurde storniert (Stornonummer: {cancellation_number}).

Grund: {data.reason}

Das Stornodokument liegt diesem Schreiben bei.

""") 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