Fix: Rechnungs-PDF komplett neu — DIN-5008-Layout, Überlagerung behoben, Bankverbindung, Footer, Deutsche Formatierung
This commit is contained in:
parent
fa513be7f5
commit
95b70d5119
1 changed files with 210 additions and 86 deletions
|
|
@ -61,116 +61,240 @@ def _next_invoice_number(conn, prefix="RG"):
|
|||
|
||||
|
||||
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", "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", "")
|
||||
INHABER = os.getenv("RECHNUNG_INHABER", "René 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) # Ban Yaro Orange
|
||||
DK = (30, 30, 30) # Dunkelgrau Text
|
||||
GY = (130, 130, 130) # Grau
|
||||
LG = (245, 245, 245) # Hellgrau Hintergrund
|
||||
WH = (255, 255, 255) # Weiss
|
||||
|
||||
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 = ""
|
||||
|
||||
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_margins(20, 15, 20)
|
||||
pdf.set_auto_page_break(auto=True, margin=28)
|
||||
W = 170 # nutzbareite Breite (210 - 20 - 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)
|
||||
# ── Orangener Balken oben ─────────────────────────────────────
|
||||
pdf.set_fill_color(*OR)
|
||||
pdf.rect(20, 15, W, 1.5, "F")
|
||||
|
||||
# ── Firmenname rechts oben ────────────────────────────────────
|
||||
pdf.set_xy(20, 20)
|
||||
pdf.set_font("Helvetica", "B", 22)
|
||||
pdf.set_text_color(*OR)
|
||||
pdf.cell(W, 10, FIRMA, align="R", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
# ── Absenderadresse rechts, klein ─────────────────────────────
|
||||
pdf.set_font("Helvetica", "", 8.5)
|
||||
pdf.set_text_color(*GY)
|
||||
for line in filter(None, [INHABER, STRASSE, PLZ_ORT, EMAIL, WEBSITE]):
|
||||
pdf.set_x(20)
|
||||
pdf.cell(W, 4.5, line, align="R", new_x="LMARGIN", new_y="NEXT")
|
||||
|
||||
# ── Absenderzeile über Empfängerfeld (DIN 5008) ───────────────
|
||||
sender_ref = " · ".join(filter(None, [FIRMA, INHABER, STRASSE, PLZ_ORT]))
|
||||
pdf.set_xy(20, 56)
|
||||
pdf.set_font("Helvetica", "", 6.5)
|
||||
pdf.set_text_color(*GY)
|
||||
pdf.cell(85, 4, sender_ref)
|
||||
pdf.set_draw_color(*GY)
|
||||
pdf.set_line_width(0.15)
|
||||
pdf.line(20, 60.5, 105, 60.5)
|
||||
|
||||
# ── Empfänger (links, DIN-5008-Fensterfeld) ───────────────────
|
||||
pdf.set_xy(20, 63)
|
||||
pdf.set_font("Helvetica", "B", 10)
|
||||
pdf.cell(0, 5, "Rechnungsempfänger:", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_text_color(*DK)
|
||||
pdf.cell(85, 5.5, invoice["recipient_name"], 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)
|
||||
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, 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, invoice["recipient_email"])
|
||||
|
||||
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)
|
||||
# ── Info-Block rechts (auf Empfänger-Höhe) ────────────────────
|
||||
# x=110, label 35mm + wert 25mm = 60mm → endet bei 170mm ✓
|
||||
info_rows = [
|
||||
("Rechnungsnummer", invoice["invoice_number"]),
|
||||
("Datum", fdate(invoice.get("created_at", ""))),
|
||||
("Faellig bis", due_date),
|
||||
]
|
||||
if invoice.get("service_period"):
|
||||
info_rows.append(("Leistungszeitraum", invoice["service_period"]))
|
||||
|
||||
y_info = 63
|
||||
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, 100)
|
||||
pdf.set_font("Helvetica", "B", 14)
|
||||
pdf.cell(0, 10, "RECHNUNG", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.ln(4)
|
||||
pdf.set_text_color(*DK)
|
||||
pdf.cell(W, 8, 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(5)
|
||||
|
||||
pdf.set_fill_color(240, 240, 240)
|
||||
# ── Positionen-Tabelle ────────────────────────────────────────
|
||||
# Spalten: 90 + 18 + 32 + 30 = 170mm ✓
|
||||
CW = (90, 18, 32, 30)
|
||||
|
||||
pdf.set_fill_color(*OR)
|
||||
pdf.set_text_color(*WH)
|
||||
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.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)
|
||||
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.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" {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(5)
|
||||
|
||||
# ── Summenblock (rechtsbündig, x=110) ─────────────────────────
|
||||
# x=110: label 50mm + wert 30mm = 80mm → endet bei 190mm ✓
|
||||
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.5, lbl, align="R", fill=bool(bg))
|
||||
pdf.cell(30, 6.5, 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(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)
|
||||
|
||||
# ── Steuerhinweis / Kleinunternehmer ──────────────────────────
|
||||
if KLEINUNTERNEHMER:
|
||||
pdf.set_font("Helvetica", "I", 9)
|
||||
pdf.multi_cell(0, 5, "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.")
|
||||
pdf.ln(4)
|
||||
pdf.set_x(20)
|
||||
pdf.set_font("Helvetica", "I", 8.5)
|
||||
pdf.set_text_color(*GY)
|
||||
pdf.multi_cell(W, 5, "Gem. § 19 UStG wird keine Umsatzsteuer berechnet.")
|
||||
|
||||
# ── Zahlungsinfo-Box ──────────────────────────────────────────
|
||||
if IBAN or due_date:
|
||||
pdf.ln(6)
|
||||
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")
|
||||
|
||||
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")
|
||||
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"]))
|
||||
|
||||
if invoice["notes"]:
|
||||
pdf.ln(4)
|
||||
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")
|
||||
|
||||
# Linker oranger Akzentbalken
|
||||
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(5)
|
||||
pdf.set_x(20)
|
||||
pdf.set_font("Helvetica", "I", 9)
|
||||
pdf.multi_cell(0, 5, invoice["notes"])
|
||||
pdf.set_text_color(*GY)
|
||||
pdf.multi_cell(W, 5, str(invoice["notes"]))
|
||||
|
||||
# ── Footer mit Pflichtangaben ─────────────────────────────────
|
||||
pdf.set_y(-18)
|
||||
pdf.set_draw_color(*OR)
|
||||
pdf.set_line_width(0.4)
|
||||
pdf.line(20, pdf.get_y(), 190, pdf.get_y())
|
||||
pdf.ln(2)
|
||||
|
||||
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())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue