Feature: Rechnungs-System (invoices) — Backend komplett

- DB-Migration: invoices + invoice_items Tabellen inkl. Indizes
- routes/invoices.py: vollständiger Admin-Router (prefix /api/admin/invoices)
  - CRUD: Liste, Detail, Erstellen, Senden, Bezahlen, Stornieren
  - PDF-Generierung via fpdf2 mit §14-UStG-Pflichtangaben (Kleinunternehmer-Hinweis)
  - Cashflow-Übersicht und Quartalsbericht
  - PDF-Download-Endpunkt
  - Speicherung in /scaninput + optionaler Paperless-Upload
- mailer.py: send_email() + Backends um optionale PDF-Anhänge erweitert
  (Brevo: base64, SMTP: MIMEApplication)
- main.py: invoices_router registriert
- docker-compose.yml: /volume1/scaninput:/scaninput Volume hinzugefügt
This commit is contained in:
rene 2026-05-15 10:04:23 +02:00
parent 9c359bb07e
commit b68a12587a
5 changed files with 570 additions and 10 deletions

View file

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

View file

@ -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 <noreply@banyaro.app>" 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:

View file

@ -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)
# ------------------------------------------------------------------

489
backend/routes/invoices.py Normal file
View file

@ -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 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:
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"""
<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.")
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)

View file

@ -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: