diff --git a/backend/database.py b/backend/database.py index f5aee7b..ec362bc 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2398,6 +2398,52 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration route_dogs fehlgeschlagen: {e}") + # Rechnungs-System + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_number TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id), + recipient_name TEXT NOT NULL, + recipient_email TEXT NOT NULL, + recipient_address TEXT, + description TEXT NOT NULL, + service_period TEXT, + amount_net REAL NOT NULL, + discount_pct REAL DEFAULT 0, + discount_amount REAL DEFAULT 0, + amount_after_discount REAL NOT NULL, + tax_rate REAL DEFAULT 0, + tax_amount REAL DEFAULT 0, + amount_gross REAL NOT NULL, + status TEXT DEFAULT 'draft', + notes TEXT, + created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + sent_at TEXT, + paid_at TEXT, + paid_amount REAL, + cancelled_at TEXT, + cancellation_reason TEXT, + cancellation_number TEXT + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_invoices_user ON invoices(user_id)") + conn.execute(""" + CREATE TABLE IF NOT EXISTS invoice_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + quantity REAL NOT NULL DEFAULT 1, + unit_price REAL NOT NULL, + total REAL NOT NULL + ) + """) + logger.info("Migration: invoices + invoice_items bereit.") + except Exception as e: + logger.warning(f"Migration invoices: {e}") + def _seed_help_articles(conn): """Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist.""" diff --git a/backend/mailer.py b/backend/mailer.py index 344fe4f..03d9228 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -6,11 +6,13 @@ Unterstützt zwei Backends (wird automatisch gewählt): """ import os +import base64 import smtplib import asyncio import logging from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from email.mime.application import MIMEApplication import httpx @@ -33,9 +35,7 @@ APP_URL = os.getenv("APP_URL", "https://banyaro.app") # ------------------------------------------------------------------ # Brevo REST-API # ------------------------------------------------------------------ -async def _send_brevo(to: str, subject: str, html: str, plain: str): - # Absender-Name und -Adresse aus SMTP_FROM parsen - # Format: "Ban Yaro " oder "noreply@banyaro.app" +async def _send_brevo(to: str, subject: str, html: str, plain: str, attachments: list | None = None): from_raw = SMTP_FROM if "<" in from_raw: from_name = from_raw[:from_raw.index("<")].strip() @@ -52,6 +52,14 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str): "textContent": plain, "headers": {"X-Mailin-Track-Click": "0", "X-Mailin-Track-Opens": "0"}, } + if attachments: + payload["attachment"] = [ + { + "name": a["filename"], + "content": base64.b64encode(a["content"]).decode("ascii"), + } + for a in attachments + ] async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( BREVO_API_URL, @@ -64,13 +72,25 @@ async def _send_brevo(to: str, subject: str, html: str, plain: str): # ------------------------------------------------------------------ # SMTP Fallback # ------------------------------------------------------------------ -def _send_smtp_sync(to: str, subject: str, html: str, plain: str): - msg = MIMEMultipart("alternative") +def _send_smtp_sync(to: str, subject: str, html: str, plain: str, attachments: list | None = None): + if attachments: + msg = MIMEMultipart("mixed") + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(plain, "plain", "utf-8")) + alt.attach(MIMEText(html, "html", "utf-8")) + msg.attach(alt) + for a in attachments: + part = MIMEApplication(a["content"], Name=a["filename"]) + part["Content-Disposition"] = f'attachment; filename="{a["filename"]}"' + msg.attach(part) + else: + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(plain, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) + msg["Subject"] = subject msg["From"] = SMTP_FROM msg["To"] = to - msg.attach(MIMEText(plain, "plain", "utf-8")) - msg.attach(MIMEText(html, "html", "utf-8")) with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s: s.ehlo() @@ -83,10 +103,10 @@ def _send_smtp_sync(to: str, subject: str, html: str, plain: str): # ------------------------------------------------------------------ # Öffentliche Funktion # ------------------------------------------------------------------ -async def send_email(to: str, subject: str, html: str, plain: str = ""): +async def send_email(to: str, subject: str, html: str, plain: str = "", attachments: list | None = None): if BREVO_API_KEY: try: - await _send_brevo(to, subject, html, plain) + await _send_brevo(to, subject, html, plain, attachments) logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}") return except Exception as e: @@ -96,7 +116,9 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""): if SMTP_HOST: loop = asyncio.get_event_loop() try: - await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain) + await loop.run_in_executor( + None, _send_smtp_sync, to, subject, html, plain, attachments + ) logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}") return except Exception as e: diff --git a/backend/main.py b/backend/main.py index 8f75144..338c427 100644 --- a/backend/main.py +++ b/backend/main.py @@ -253,6 +253,7 @@ from routes.challenges import router as challenges_router from routes.gassi_zeiten import router as gassi_zeiten_router from routes.help import router as help_router from routes.feedback import router as feedback_router +from routes.invoices import router as invoices_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -317,6 +318,7 @@ app.include_router(challenges_router, prefix="/api/challenges", ta app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"]) app.include_router(help_router, prefix="/api/help", tags=["Hilfe/FAQ"]) app.include_router(feedback_router, prefix="/api/feedback", tags=["Feedback"]) +app.include_router(invoices_router) # ------------------------------------------------------------------ diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py new file mode 100644 index 0000000..2f53bae --- /dev/null +++ b/backend/routes/invoices.py @@ -0,0 +1,489 @@ +"""BAN YARO — Rechnungs-System (Admin)""" + +import os +import logging +from datetime import datetime +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel +from database import db +from auth import require_admin +import mailer + +router = APIRouter(prefix="/api/admin/invoices", tags=["invoices"]) +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class InvoiceItem(BaseModel): + description: str + quantity: float = 1.0 + unit_price: float + + +class InvoiceCreate(BaseModel): + user_id: Optional[int] = None + recipient_name: str + recipient_email: str + recipient_address: Optional[str] = None + items: List[InvoiceItem] + discount_pct: Optional[float] = 0.0 + service_period: Optional[str] = None + notes: Optional[str] = None + + +class PayBody(BaseModel): + paid_at: str + paid_amount: float + + +class CancelBody(BaseModel): + reason: str + + +# ------------------------------------------------------------------ +# Hilfsfunktionen +# ------------------------------------------------------------------ +def _next_invoice_number(conn, prefix="RG"): + year = datetime.now().year + last = conn.execute( + "SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1", + (f"{prefix}-{year}-%",) + ).fetchone() + if last: + n = int(last[0].split("-")[-1]) + 1 + else: + n = 1 + return f"{prefix}-{year}-{n:04d}" + + +def _generate_pdf(invoice, items) -> bytes: + KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true" + STEUERNUMMER = os.getenv("STEUERNUMMER", "") + INHABER = os.getenv("RECHNUNG_INHABER", "René Degelmann") + GESCHAEFTSNAME = os.getenv("RECHNUNG_GESCHAEFTSNAME", "Ban Yaro") + STRASSE = os.getenv("RECHNUNG_STRASSE", "") + PLZ_ORT = os.getenv("RECHNUNG_PLZ_ORT", "") + EMAIL_ABSENDER = os.getenv("RECHNUNG_EMAIL", "hallo@banyaro.app") + BANK_IBAN = os.getenv("RECHNUNG_IBAN", "") + BANK_BIC = os.getenv("RECHNUNG_BIC", "") + BANK_BANK = os.getenv("RECHNUNG_BANK", "") + + from fpdf import FPDF + pdf = FPDF() + pdf.add_page() + pdf.set_margins(20, 20, 20) + pdf.set_auto_page_break(auto=True, margin=20) + + pdf.set_font("Helvetica", "B", 18) + pdf.cell(0, 10, GESCHAEFTSNAME, new_x="LMARGIN", new_y="NEXT") + pdf.set_font("Helvetica", "", 10) + pdf.cell(0, 5, INHABER, new_x="LMARGIN", new_y="NEXT") + if STRASSE: + pdf.cell(0, 5, STRASSE, new_x="LMARGIN", new_y="NEXT") + if PLZ_ORT: + pdf.cell(0, 5, PLZ_ORT, new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 5, EMAIL_ABSENDER, new_x="LMARGIN", new_y="NEXT") + pdf.ln(10) + + pdf.set_font("Helvetica", "B", 10) + pdf.cell(0, 5, "Rechnungsempfänger:", new_x="LMARGIN", new_y="NEXT") + pdf.set_font("Helvetica", "", 10) + pdf.cell(0, 5, invoice["recipient_name"], new_x="LMARGIN", new_y="NEXT") + if invoice["recipient_address"]: + for line in invoice["recipient_address"].split("\n"): + pdf.cell(0, 5, line, new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 5, invoice["recipient_email"], new_x="LMARGIN", new_y="NEXT") + pdf.ln(8) + + pdf.set_font("Helvetica", "B", 10) + pdf.cell(0, 5, f"Rechnungsnummer: {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT") + pdf.set_font("Helvetica", "", 10) + rg_date = invoice["created_at"][:10] if invoice["created_at"] else "" + pdf.cell(0, 5, f"Rechnungsdatum: {rg_date}", new_x="LMARGIN", new_y="NEXT") + if invoice["service_period"]: + pdf.cell(0, 5, f"Leistungszeitraum: {invoice['service_period']}", new_x="LMARGIN", new_y="NEXT") + if STEUERNUMMER: + pdf.cell(0, 5, f"Steuernummer: {STEUERNUMMER}", new_x="LMARGIN", new_y="NEXT") + pdf.ln(8) + + pdf.set_font("Helvetica", "B", 14) + pdf.cell(0, 10, "RECHNUNG", new_x="LMARGIN", new_y="NEXT") + pdf.ln(4) + + pdf.set_fill_color(240, 240, 240) + pdf.set_font("Helvetica", "B", 9) + pdf.cell(90, 7, "Beschreibung", border=1, fill=True) + pdf.cell(20, 7, "Menge", border=1, fill=True, align="C") + pdf.cell(35, 7, "Einzelpreis", border=1, fill=True, align="R") + pdf.cell(35, 7, "Gesamt", border=1, fill=True, align="R", new_x="LMARGIN", new_y="NEXT") + + pdf.set_font("Helvetica", "", 9) + for item in items: + qty_str = f"{item['quantity']:.2f}".rstrip("0").rstrip(".") + pdf.cell(90, 7, str(item["description"])[:60], border=1) + pdf.cell(20, 7, qty_str, border=1, align="C") + pdf.cell(35, 7, f"{item['unit_price']:.2f} EUR", border=1, align="R") + pdf.cell(35, 7, f"{item['total']:.2f} EUR", border=1, align="R", new_x="LMARGIN", new_y="NEXT") + + pdf.ln(4) + + def right_row(label, value, bold=False): + pdf.set_font("Helvetica", "B" if bold else "", 10) + pdf.cell(120, 6, "") + pdf.cell(40, 6, label, align="R") + pdf.cell(10, 6, "") + pdf.cell(0, 6, value, align="R", new_x="LMARGIN", new_y="NEXT") + + right_row("Nettobetrag:", f"{invoice['amount_net']:.2f} EUR") + if invoice["discount_pct"] and invoice["discount_pct"] > 0: + right_row(f"Rabatt ({invoice['discount_pct']:.0f}%):", f"-{invoice['discount_amount']:.2f} EUR") + right_row("Nach Rabatt:", f"{invoice['amount_after_discount']:.2f} EUR") + + if not KLEINUNTERNEHMER: + right_row(f"MwSt. {invoice['tax_rate']:.0f}%:", f"{invoice['tax_amount']:.2f} EUR") + + pdf.ln(2) + pdf.set_draw_color(0, 0, 0) + right_row("Gesamtbetrag:", f"{invoice['amount_gross']:.2f} EUR", bold=True) + + pdf.ln(8) + + if KLEINUNTERNEHMER: + pdf.set_font("Helvetica", "I", 9) + pdf.multi_cell(0, 5, "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.") + pdf.ln(4) + + if BANK_IBAN: + pdf.set_font("Helvetica", "", 9) + pdf.cell(0, 5, "Bitte überweisen Sie den Betrag innerhalb von 14 Tagen auf:", new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 5, f"IBAN: {BANK_IBAN}", new_x="LMARGIN", new_y="NEXT") + if BANK_BIC: + pdf.cell(0, 5, f"BIC: {BANK_BIC}", new_x="LMARGIN", new_y="NEXT") + if BANK_BANK: + pdf.cell(0, 5, f"Bank: {BANK_BANK}", new_x="LMARGIN", new_y="NEXT") + pdf.cell(0, 5, f"Verwendungszweck: {invoice['invoice_number']}", new_x="LMARGIN", new_y="NEXT") + + if invoice["notes"]: + pdf.ln(4) + pdf.set_font("Helvetica", "I", 9) + pdf.multi_cell(0, 5, invoice["notes"]) + + return bytes(pdf.output()) + + +async def _save_to_paperless(pdf_bytes: bytes, invoice_number: str, filename: str): + scaninput = os.getenv("SCANINPUT_DIR", "/scaninput") + os.makedirs(scaninput, exist_ok=True) + path = os.path.join(scaninput, filename) + with open(path, "wb") as f: + f.write(pdf_bytes) + + paperless_url = os.getenv("PAPERLESS_URL", "") + paperless_token = os.getenv("PAPERLESS_TOKEN", "") + if paperless_url and paperless_token: + try: + import httpx + async with httpx.AsyncClient(timeout=30) as client: + await client.post( + f"{paperless_url}/api/documents/post_document/", + headers={"Authorization": f"Token {paperless_token}"}, + files={"document": (filename, pdf_bytes, "application/pdf")}, + data={"title": invoice_number, "tags": "banyaro,Rechnung"}, + ) + except Exception as e: + logger.warning(f"Paperless upload failed: {e}") + + +def _row_to_dict(row) -> dict: + return dict(row) + + +def _fetch_items(conn, invoice_id: int) -> list: + rows = conn.execute( + "SELECT * FROM invoice_items WHERE invoice_id=? ORDER BY id", + (invoice_id,) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# Endpoints +# ------------------------------------------------------------------ +@router.get("") +def list_invoices(status: Optional[str] = None, admin=Depends(require_admin)): + with db() as conn: + if status: + rows = conn.execute( + "SELECT * FROM invoices WHERE status=? ORDER BY id DESC", + (status,) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM invoices ORDER BY id DESC" + ).fetchall() + return [_row_to_dict(r) for r in rows] + + +@router.get("/cashflow") +def get_cashflow(admin=Depends(require_admin)): + with db() as conn: + monthly = conn.execute(""" + SELECT substr(created_at, 1, 7) AS month, + SUM(amount_gross) AS revenue, + COUNT(*) AS count + FROM invoices + WHERE status IN ('sent', 'paid') + GROUP BY month + ORDER BY month DESC + """).fetchall() + + year = datetime.now().year + total_year = conn.execute( + "SELECT COALESCE(SUM(amount_gross),0) FROM invoices WHERE status IN ('sent','paid') AND created_at LIKE ?", + (f"{year}%",) + ).fetchone()[0] + + total_outstanding = conn.execute( + "SELECT COALESCE(SUM(amount_gross),0) FROM invoices WHERE status='sent'" + ).fetchone()[0] + + total_paid = conn.execute( + "SELECT COALESCE(SUM(COALESCE(paid_amount, amount_gross)),0) FROM invoices WHERE status='paid'" + ).fetchone()[0] + + counts_rows = conn.execute( + "SELECT status, COUNT(*) AS n FROM invoices GROUP BY status" + ).fetchall() + + counts = {r["status"]: r["n"] for r in counts_rows} + return { + "monthly": [_row_to_dict(r) for r in monthly], + "total_year": round(total_year, 2), + "total_outstanding": round(total_outstanding, 2), + "total_paid": round(total_paid, 2), + "counts": counts, + } + + +@router.get("/quarterly/{year}/{q}") +def get_quarterly(year: int, q: int, admin=Depends(require_admin)): + if q not in (1, 2, 3, 4): + raise HTTPException(400, "Quartal muss 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: + rows = conn.execute( + "SELECT * FROM invoices WHERE created_at >= ? AND created_at <= ? ORDER BY id", + (from_date, to_date + "T23:59:59Z") + ).fetchall() + + total_net = sum(r["amount_net"] for r in rows) + total_tax = sum(r["tax_amount"] for r in rows) + total_gross = sum(r["amount_gross"] for r in rows) + + return { + "period": period, + "invoices": [_row_to_dict(r) for r in rows], + "total_net": round(total_net, 2), + "total_tax": round(total_tax, 2), + "total_gross": round(total_gross, 2), + "count": len(rows), + } + + +@router.get("/{invoice_id}") +def get_invoice(invoice_id: int, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + if not row: + raise HTTPException(404, "Rechnung nicht gefunden.") + items = _fetch_items(conn, invoice_id) + result = _row_to_dict(row) + result["items"] = items + return result + + +@router.post("", status_code=201) +def create_invoice(data: InvoiceCreate, admin=Depends(require_admin)): + if not data.items: + raise HTTPException(400, "Mindestens eine Position erforderlich.") + + KLEINUNTERNEHMER = os.getenv("KLEINUNTERNEHMER", "true").lower() == "true" + TAX_RATE = 0.0 if KLEINUNTERNEHMER else float(os.getenv("RECHNUNG_MWST", "19")) + + amount_net = round(sum(i.quantity * i.unit_price for i in data.items), 2) + discount_pct = data.discount_pct or 0.0 + discount_amount = round(amount_net * discount_pct / 100, 2) + amount_after_discount = round(amount_net - discount_amount, 2) + tax_amount = round(amount_after_discount * TAX_RATE / 100, 2) + amount_gross = round(amount_after_discount + tax_amount, 2) + + description = data.items[0].description if len(data.items) == 1 else f"{len(data.items)} Positionen" + + with db() as conn: + invoice_number = _next_invoice_number(conn) + conn.execute(""" + INSERT INTO invoices + (invoice_number, user_id, recipient_name, recipient_email, recipient_address, + description, service_period, amount_net, discount_pct, discount_amount, + amount_after_discount, tax_rate, tax_amount, amount_gross, notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + invoice_number, data.user_id, data.recipient_name, data.recipient_email, + data.recipient_address, description, data.service_period, + amount_net, discount_pct, discount_amount, + amount_after_discount, TAX_RATE, tax_amount, amount_gross, + data.notes, + )) + invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + + for item in data.items: + total = round(item.quantity * item.unit_price, 2) + conn.execute( + "INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)", + (invoice_id, item.description, item.quantity, item.unit_price, total) + ) + + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + items = _fetch_items(conn, invoice_id) + + result = _row_to_dict(row) + result["items"] = items + return result + + +@router.post("/{invoice_id}/send") +async def send_invoice(invoice_id: int, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + if not row: + raise HTTPException(404, "Rechnung nicht gefunden.") + if row["status"] == "cancelled": + raise HTTPException(400, "Stornierte Rechnung kann nicht gesendet werden.") + items = _fetch_items(conn, invoice_id) + + invoice = _row_to_dict(row) + + try: + pdf_bytes = _generate_pdf(invoice, items) + except Exception as e: + logger.error(f"PDF-Generierung fehlgeschlagen: {e}") + raise HTTPException(500, f"PDF-Generierung fehlgeschlagen: {e}") + + filename = f"{invoice['invoice_number']}_banyaro.pdf" + + try: + await _save_to_paperless(pdf_bytes, invoice["invoice_number"], filename) + except Exception as e: + logger.warning(f"Paperless-Speicherung fehlgeschlagen: {e}") + + import base64 + body_html = f""" +

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.") + conn.execute( + "UPDATE invoices SET status='paid', paid_at=?, paid_amount=? WHERE id=?", + (data.paid_at, data.paid_amount, invoice_id) + ) + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + return _row_to_dict(row) + + +@router.post("/{invoice_id}/cancel") +def cancel_invoice(invoice_id: int, data: CancelBody, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + if not row: + raise HTTPException(404, "Rechnung nicht gefunden.") + if row["status"] == "cancelled": + raise HTTPException(400, "Rechnung ist bereits storniert.") + cancellation_number = _next_invoice_number(conn, "ST") + now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z") + conn.execute( + "UPDATE invoices SET status='cancelled', cancelled_at=?, cancellation_reason=?, cancellation_number=? WHERE id=?", + (now, data.reason, cancellation_number, invoice_id) + ) + row = conn.execute("SELECT * FROM invoices WHERE id=?", (invoice_id,)).fetchone() + return _row_to_dict(row) diff --git a/docker-compose.yml b/docker-compose.yml index 772bb22..019c40d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - "3010:8000" # DS-intern, NPM leitet banyaro.app weiter volumes: - ./data:/data # SQLite + Media persistent + - /volume1/scaninput:/scaninput env_file: - .env environment: