From 95b70d51194b2ef926ba75d974497612db2e6887 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 15 May 2026 10:45:49 +0200 Subject: [PATCH] =?UTF-8?q?Fix:=20Rechnungs-PDF=20komplett=20neu=20?= =?UTF-8?q?=E2=80=94=20DIN-5008-Layout,=20=C3=9Cberlagerung=20behoben,=20B?= =?UTF-8?q?ankverbindung,=20Footer,=20Deutsche=20Formatierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/invoices.py | 296 ++++++++++++++++++++++++++----------- 1 file changed, 210 insertions(+), 86 deletions(-) diff --git a/backend/routes/invoices.py b/backend/routes/invoices.py index 2f53bae..a59e231 100644 --- a/backend/routes/invoices.py +++ b/backend/routes/invoices.py @@ -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())