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

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