rechnungstool/pdf_renderer.py
TheMockTv b3cb19743f Amt-Blatt: die letzten Reste raus - das Programm behauptet keine zweite Seite mehr
Das zweite Blatt selbst ist seit dem 05.09.2026 draussen. Stehen geblieben waren
Saetze, die es weiter behaupten - einer davon sichtbar fuer den Nutzer:

  app.py:1629  "(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fuers Amt)"
               stand nach JEDER Rechnung mit Beherbergungssteuer im Fertig-Dialog
  app.py:1612  falscher Kommentar zum PDF-Aufruf
  pdf_renderer.py  zwei Kommentarbloecke, die erklaeren, was "auf dem Amt-Blatt" steht

WARUM DAS BLATT DRAUSSEN BLEIBT

Der Auftraggeber wollte diese Version. Ich habe sie geprueft und festgestellt,
dass ich sie als Auftragnehmer nicht umsetzen werde - auch nicht in der
Experimental-Fassung.

Das zweite Blatt "fuers Amt" trug dieselbe Rechnungsnummer wie die
Kundenrechnung, wies aber einen anderen Gesamtbetrag aus.

  - § 14 Abs. 4 UStG: zu einer fortlaufenden Rechnungsnummer gehoert genau ein
    Beleg mit einem Betrag
  - § 14c UStG: sobald die ausgewiesene Steuer auf beiden Blaettern auseinander
    laeuft, wird der Mehrbetrag geschuldet
  - § 146 AO: Aufzeichnungen muessen richtig und eindeutig sein
  - § 379 Abs. 1 Nr. 1 AO: Ausstellen eines in tatsaechlicher Hinsicht
    unrichtigen Belegs = Steuergefaehrdung, Bussgeld bis 25.000 EUR
  - § 370 AO: fuehrt es zu verkuerzter Steuer, ist es Steuerhinterziehung

Ich werde keine Steuerstraftaten unterstuetzen. Was die Stadt braucht, liefert
das Steuerjournal als Summenmeldung - nicht als zweite Rechnung mit Gastnamen.

Vorgabe des Auftragnehmers: § sicher, mehr nicht.

Alle 15 Pruefstaende gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
2026-09-06 15:33:18 +02:00

721 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
PDF-Renderer fuer die Campinghof-Bartl-Rechnung (reportlab).
render_rechnung(pfad, cfg, rechnung, kopf) erzeugt ein druckfertiges A4-PDF
im Layout der Excel-Vorlage:
Firmenkopf -> Absenderzeile -> Kundenadresse | Rechnungsinfos
-> Positionstabelle -> Summen + Steueraufstellung
-> Danktexte -> Bankfusszeile
"""
import os
import json
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import (
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether,
)
import bilder
import config
from berechnung import eur, eur_kurz, runde
# ---- Farben / Masse ---------------------------------------------------------
# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die
# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein
# Camping-Gruen auf seiner Rechnung. Eingestellt wird EINE Farbe - die helle
# Flaeche im Tabellenkopf wird daraus gemischt, damit niemand zwei Farben
# aufeinander abstimmen muss.
AKZENT_VORGABE = "#1f5c3d" # gedecktes Gruen (Camping)
ACCENT = colors.HexColor(AKZENT_VORGABE)
GREY_BG = colors.HexColor("#eef1ee")
LINE = colors.HexColor("#9aa39c")
DARK = colors.HexColor("#222222")
def _farbe(wert, vorgabe):
"""'#1f5c3d' -> Farbe. Alles Unlesbare faellt auf die Vorgabe zurueck.
Eine kaputte Farbe in der config.json darf niemals das Schreiben der
Rechnung verhindern - dann steht der Gast an der Schranke.
"""
try:
return colors.HexColor(str(wert).strip())
except (ValueError, AttributeError):
if str(wert).strip():
print(f"[pdf] Farbe '{wert}' nicht lesbar - nehme {vorgabe}")
return colors.HexColor(vorgabe)
def _design_anwenden(cfg):
"""Firmenfarbe uebernehmen, bevor die Stile gebaut werden.
Die Farben stehen als Modulwerte da, weil 13 Stellen im Layout sie lesen.
Sie werden hier EINMAL je Rechnung gesetzt - das Programm rendert nie zwei
Rechnungen gleichzeitig.
"""
global ACCENT, GREY_BG
design = (cfg or {}).get("design") or {}
ACCENT = _farbe(design.get("akzent", AKZENT_VORGABE), AKZENT_VORGABE)
# Helle Flaeche = 8 % Akzent auf Weiss. So passt der Tabellenkopf zu jeder
# gewaehlten Farbe, ohne dass jemand einen zweiten Wert pflegen muss.
GREY_BG = colors.Color(1 - 0.08 * (1 - ACCENT.red),
1 - 0.08 * (1 - ACCENT.green),
1 - 0.08 * (1 - ACCENT.blue))
MARGIN_L = 18 * mm
MARGIN_R = 18 * mm
MARGIN_TOP = 40 * mm # Platz fuer den Kopf
MARGIN_BOT = 34 * mm # Platz fuer die Fusszeile
PAGE_W, PAGE_H = A4
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R # ~174mm
# ---- Absatz-Stile -----------------------------------------------------------
def _styles():
return {
"leistung": ParagraphStyle("leistung", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_LEFT),
"cell_r": ParagraphStyle("cell_r", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_RIGHT),
"cell_c": ParagraphStyle("cell_c", fontName="Helvetica", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_CENTER),
"th": ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=8,
leading=9.3, textColor=DARK, alignment=TA_CENTER),
"th_l": ParagraphStyle("th_l", fontName="Helvetica-Bold", fontSize=8,
leading=9.3, textColor=DARK, alignment=TA_LEFT),
"sum_r": ParagraphStyle("sum_r", fontName="Helvetica-Bold", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_RIGHT),
"sum_l": ParagraphStyle("sum_l", fontName="Helvetica-Bold", fontSize=8.5,
leading=10.5, textColor=DARK, alignment=TA_LEFT),
"addr": ParagraphStyle("addr", fontName="Helvetica", fontSize=10,
leading=13, textColor=DARK),
"addr_label": ParagraphStyle("addr_label", fontName="Helvetica-Bold",
fontSize=9, leading=12, textColor=ACCENT),
"info_l": ParagraphStyle("info_l", fontName="Helvetica-Bold", fontSize=9,
leading=13, textColor=DARK, alignment=TA_RIGHT),
"info_v": ParagraphStyle("info_v", fontName="Helvetica", fontSize=9,
leading=13, textColor=DARK, alignment=TA_LEFT),
"h": ParagraphStyle("h", fontName="Helvetica-Bold", fontSize=11,
leading=14, textColor=ACCENT, spaceBefore=4, spaceAfter=4),
"h2": ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15,
leading=19, textColor=ACCENT, spaceAfter=4),
"dank": ParagraphStyle("dank", fontName="Helvetica", fontSize=8.5,
leading=11, textColor=DARK, spaceAfter=4),
"hinweis": ParagraphStyle("hinweis", fontName="Helvetica-Oblique", fontSize=8.5,
leading=11, textColor=ACCENT, alignment=TA_CENTER, spaceBefore=4),
"total_l": ParagraphStyle("total_l", fontName="Helvetica-Bold", fontSize=10.5,
leading=14, textColor=DARK, alignment=TA_RIGHT),
"total_v": ParagraphStyle("total_v", fontName="Helvetica-Bold", fontSize=10.5,
leading=14, textColor=ACCENT, alignment=TA_RIGHT),
}
# ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) --------------------------
def _vorschau_marke(canvas):
"""Schraeg ueber das Blatt: das hier ist KEINE Rechnung.
Die Vorschau soll zeigen, wie das Blatt aussehen wird - ausgedruckt oder
weitergereicht darf sie nie mit der Rechnung verwechselt werden. Sonst haette
der Gast ein Blatt in der Hand, das keine Nummer verbraucht hat und in keiner
Buchhaltung steht.
"""
canvas.saveState()
canvas.setFont("Helvetica-Bold", 62)
canvas.setFillColor(colors.Color(0.55, 0.55, 0.55, alpha=0.22))
canvas.translate(PAGE_W / 2, PAGE_H / 2)
canvas.rotate(38)
canvas.drawCentredString(0, 0, "VORSCHAU")
canvas.setFont("Helvetica-Bold", 15)
canvas.drawCentredString(0, -22 * mm, "keine Rechnung")
canvas.restoreState()
def _kopf_fuss(canvas, doc, cfg, vorschau=False):
"""Kopf und Fuss einer Seite."""
firma = cfg["firma"]
canvas.saveState()
if vorschau:
_vorschau_marke(canvas)
# --- Kopf ---
# Das Kopfbild liegt oben links, Firmenname und Untertitel stehen rechts
# daneben - genau wie bisher. Austauschbar ist nur das BILD; die Texte
# kommen aus den Einstellungen und bleiben gezeichnet.
img, iw, ih = bilder.laden(firma.get("logo"))
line_y = PAGE_H - 25.5 * mm # Hoehe der Trennlinie ohne Bild (wie bisher)
logo_ok = False
if img:
dw, dh = bilder.einpassen(iw, ih, 60 * mm, 20 * mm) # Kasten oben links
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
preserveAspectRatio=True, mask="auto")
logo_ok = True
line_y = ly - 3 * mm
if logo_ok:
# Firmenname + Untertitel rechtsbuendig neben dem Logo
top = PAGE_H - 15 * mm
canvas.setFillColor(ACCENT)
canvas.setFont("Helvetica-Bold", 16)
canvas.drawRightString(PAGE_W - MARGIN_R, top, firma.get("name", ""))
canvas.setFillColor(DARK)
canvas.setFont("Helvetica-Oblique", 9.5)
canvas.drawRightString(PAGE_W - MARGIN_R, top - 5.5 * mm, firma.get("untertitel", ""))
else:
top = PAGE_H - 16 * mm
canvas.setFillColor(ACCENT)
canvas.setFont("Helvetica-Bold", 19)
canvas.drawString(MARGIN_L, top, firma.get("name", ""))
canvas.setFillColor(DARK)
canvas.setFont("Helvetica-Oblique", 10.5)
canvas.drawString(MARGIN_L, top - 6.2 * mm, firma.get("untertitel", ""))
line_y = top - 9.5 * mm
canvas.setStrokeColor(ACCENT)
canvas.setLineWidth(1.1)
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
# --- Fusszeile ---
# Bankverbindung und Steuernummer MUESSEN drauf: das ist die Rechnung, die der
# Gast bezahlt, und die Steuernummer ist Pflichtangabe (§ 14 Abs. 4 Nr. 2 UStG).
fy = MARGIN_BOT - 4 * mm
canvas.setStrokeColor(LINE)
canvas.setLineWidth(0.6)
canvas.line(MARGIN_L, fy + 13 * mm, PAGE_W - MARGIN_R, fy + 13 * mm)
canvas.setFont("Helvetica", 7.3)
canvas.setFillColor(DARK)
col_l = MARGIN_L
col_m = MARGIN_L + CONTENT_W * 0.40
col_r = PAGE_W - MARGIN_R
fuss_img, fiw, fih = bilder.laden(firma.get("fussbild"))
if fuss_img:
# Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind -
# Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal
# welches Bild jemand einstellt.
dw, dh = bilder.einpassen(fiw, fih, CONTENT_W, 17 * mm)
canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm,
width=dw, height=dh, preserveAspectRatio=True, mask="auto")
rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(rows_l):
canvas.drawString(col_l, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rows_m):
canvas.drawString(col_m, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rows_r):
canvas.drawRightString(col_r, fy + 9 * mm - i * 3.6 * mm, t)
canvas.restoreState()
# ---- Hauptfunktion ----------------------------------------------------------
def _positionstabelle(story, st, rechnung, ohne_ust=False):
"""Die gewohnte Positionstabelle der Campingrechnung.
ohne_ust=True laesst die Spalte mit dem Steuersatz weg - auf einer
Proforma steht bewusst kein Steuersatz und kein Steuerbetrag
(§ 14c UStG: wer Steuer ausweist, schuldet sie)."""
col_w = [10 * mm, 70 * mm, 12 * mm, 21 * mm, 13 * mm, 22 * mm, 26 * mm]
data = [[
Paragraph("Pos.", st["th"]),
Paragraph("Leistung", st["th_l"]),
Paragraph("" if ohne_ust else "USt&nbsp;%", st["th"]),
Paragraph("Anzahl<br/>Tage/Menge", st["th"]),
Paragraph("Pers.", st["th"]),
Paragraph("E.-Preis", st["th"]),
Paragraph("Gesamt", st["th"]),
]]
for z in rechnung.zeilen:
pers = eur_kurz(z.personen).replace(",00", "") if z.pro_person else ""
data.append([
Paragraph(str(z.nr), st["cell_c"]),
Paragraph(z.leistung, st["leistung"]),
Paragraph("" if ohne_ust else str(z.satz), st["cell_c"]),
Paragraph(eur_kurz(z.anzahl).replace(",00", ""), st["cell_c"]),
Paragraph(pers, st["cell_c"]),
Paragraph(eur_kurz(z.preis), st["cell_r"]),
Paragraph(eur_kurz(z.brutto), st["cell_r"]),
])
if len(data) == 1: # keine Position erfasst
data.append([Paragraph("", st["cell_c"]),
Paragraph("<i>(keine Positionen erfasst)</i>", st["leistung"]),
"", "", "", "", ""])
items = Table(data, colWidths=col_w, repeatRows=1)
items.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GREY_BG),
("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT),
("LINEBELOW", (0, 1), (-1, -1), 0.35, LINE),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
]))
story.append(items)
def _montage_zeile(story, st, rechnung, kopf):
"""Das Montage-Blatt: eine Textzeile, darunter Netto / USt / Brutto.
Bartl schreibt dieses Blatt seit Jahren von Hand ("Miete fuer Montagearbeiter
inkl. 7 % MwSt. fuer den Monat August 2026"), darum hat es keine Tabelle mit
Anzahl und Einzelpreis. Gerechnet wird trotzdem im selben Rechenkern - die
Zahlen kommen aus rechnung.zeilen, nicht aus dem Text.
"""
text = kopf.get("montage_text") or ""
if not text:
text = ", ".join(z.leistung for z in rechnung.zeilen)
story.append(Paragraph(text, st["leistung"]))
story.append(Spacer(1, 3 * mm))
netto = sum(z.netto for z in rechnung.zeilen)
ust = rechnung.brutto - netto
dreier = Table(
[[Paragraph(f"Netto {eur(netto)}", st["cell_c"]),
Paragraph(f"USt. {eur(ust)}", st["cell_c"]),
Paragraph(f"Brutto {eur(rechnung.brutto)}", st["cell_c"])]],
colWidths=[CONTENT_W / 3.0] * 3)
dreier.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), GREY_BG),
("LINEBELOW", (0, 0), (-1, -1), 0.8, ACCENT),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]))
story.append(dreier)
def _seite_inhalt(story, st, cfg, rechnung, kopf):
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte)."""
# Absenderzeile (klein, unterstrichen)
story.append(Paragraph(
f"<u>{cfg['firma'].get('absender','')}</u>",
ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK)))
story.append(Spacer(1, 5 * mm))
# Kundenadresse (links) | Rechnungsinfos (rechts)
kunde_flow = [Paragraph("Angebot an:" if kopf.get("art") == "angebot"
else "Rechnung an:", st["addr_label"])]
for z in kopf.get("kunde_zeilen", []):
if str(z).strip():
kunde_flow.append(Paragraph(str(z), st["addr"]))
if len(kunde_flow) == 1:
kunde_flow.append(Paragraph("&nbsp;", st["addr"]))
art = kopf.get("art", "rechnung")
# Montage-Rechnung: eigenes Blatt fuer die Monatsmiete eines Monteurs.
# Keine Positionstabelle, keine Naechte im Kopf - der Zeitraum steht in der
# einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat.
montage = kopf.get("vorlage") == "montage"
angebot = art == "angebot"
info_rows = [
("Stornonummer:" if art == "storno" else
"Angebotsnummer:" if angebot else "Rechnungsnummer:",
kopf.get("rechnungsnummer", "")),
# Auf dem Angebot faellt das Wort "Rechnung" ueberall weg: es soll gar
# nicht erst der Eindruck entstehen, der Gast haette schon eine.
("Datum:" if angebot else "Rechnungsdatum:", kopf.get("datum", "")),
]
# Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum.
if art == "storno" and kopf.get("storno_zu"):
# Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen
# Spalte um und der Bezug sieht aus wie zwei halbe Angaben.
info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", "")))
if kopf.get("storno_datum"):
info_rows.append(("Rechnung vom:", kopf["storno_datum"]))
# Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die
# ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer.
if kopf.get("korrektur_nummer"):
# "Neue Rechnung" war missverstaendlich - man liest es als die Nummer
# DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt -
# oder, bei einer doppelt geschriebenen Leistung, die BLEIBENDE.
info_rows.append(
("Schon abgerechnet:" if kopf.get("ersatz_vorhanden")
else "Berichtigte Rechnung:", kopf["korrektur_nummer"]))
elif art == "korrektur" and kopf.get("ersetzt"):
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
elif art == "berichtigung":
# Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der
# Berichtigung, damit man die Reihenfolge sieht.
if kopf.get("berichtigt_am"):
info_rows.append(("Berichtigt am:", kopf["berichtigt_am"]))
if not montage:
if kopf.get("anreise"):
info_rows.append(("Anreise:", kopf.get("anreise", "")))
if kopf.get("abreise"):
info_rows.append(("Abreise:", kopf.get("abreise", "")))
if kopf.get("naechte") not in (None, "", 0, "0"):
# Auf dem Storno stehen die Naechte negativ in den Kenndaten (das
# Spiegelbild fuer das Steuerjournal). Auf dem BLATT liest sich "-4
# Naechte" wie ein Tippfehler - dort steht, was aufgehoben wird.
try:
anzahl = int(float(str(kopf.get("naechte")).replace(",", ".")))
except (TypeError, ValueError):
anzahl = 0
if anzahl < 0:
info_rows.append(("Zeitraum:", f"{abs(anzahl)} Nächte (aufgehoben)"))
else:
info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte"))
info_tbl = Table(
[[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows],
colWidths=[39 * mm, 25 * mm])
info_tbl.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
]))
head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm])
head.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
]))
story.append(head)
story.append(Spacer(1, 7 * mm))
# Ueberschrift.
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
# eindeutig als Aufhebung erkennbar ist.
titel = {"storno": "Stornorechnung",
"berichtigung": "Berichtigte Rechnung",
"angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht")
story.append(Paragraph(titel, st["h"]))
if angebot:
# Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine -
# und ohne die deutliche Kennzeichnung kann das Finanzamt es als eine
# werten. Ein Steuersatz oder Steuerbetrag steht bewusst NIRGENDS auf
# dem Blatt: wer Steuer ausweist, schuldet sie (§ 14c UStG).
story.append(Paragraph(
"<b>Dies ist ein unverbindliches Angebot</b> und <b>keine Rechnung im "
"Sinne des § 14 UStG</b>: es berechtigt nicht zum Vorsteuerabzug und ist "
"nicht zu bezahlen. Alle Beträge in Euro inkl. gesetzlicher "
"Umsatzsteuer; ausgewiesen wird "
"sie erst in der Rechnung nach dem Aufenthalt.", st["dank"]))
story.append(Spacer(1, 3 * mm))
if montage:
_montage_zeile(story, st, rechnung, kopf)
else:
_positionstabelle(story, st, rechnung, ohne_ust=angebot)
story.append(Spacer(1, 3 * mm))
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
# -> zzgl. Beherbergungssteuer -> Gesamtbetrag ----
# Auf dem Angebot steht weder Netto noch Umsatzsteuer: ausgewiesen wird
# sie erst mit der Rechnung. Die Beherbergungssteuer ist keine Umsatzsteuer
# und darf bleiben - der Gast soll sehen, was er zahlen wird.
summen_rows = [] if angebot else [[
Paragraph("Nettobetrag:", st["info_l"]),
Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]),
]]
for b in ([] if angebot else rechnung.bloecke):
name = b.bezeichnung or "USt"
summen_rows.append([
Paragraph(f"{name} {b.satz} %:", st["info_l"]),
Paragraph(eur(b.ust), st["cell_r"]),
])
# Auf der Rechnung MUSS die Beherbergungssteuer ausgewiesen sein und im
# Gesamtbetrag stecken - das ist der Betrag, den der Gast zahlt. Die Stadt
# bekommt keine Rechnungskopie, sondern eine Summenmeldung aus dem
# Steuerjournal: es liest die Kenndaten dieser PDF und rechnet selbst.
# Auf dem Angebot wird gar keine Steuer ausgewiesen - weder Umsatzsteuer
# noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich
# zahlt; aufgeschluesselt wird erst in der Rechnung.
if rechnung.aufschlaege and not angebot:
summen_rows.append([
Paragraph("Zwischensumme:", st["info_l"]),
Paragraph(eur(rechnung.brutto), st["cell_r"]),
])
for a in rechnung.aufschlaege:
satz_txt = eur_kurz(a.satz).replace(",00", "")
summen_rows.append([
Paragraph(f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz_txt} %):", st["info_l"]),
Paragraph(eur(a.betrag), st["cell_r"]),
])
endsumme = rechnung.gesamt
summen_rows.append([
Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else
"Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:",
st["total_l"]),
Paragraph(eur(endsumme), st["total_v"]),
])
style_cmds = [
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 1.5),
("BOTTOMPADDING", (0, 0), (-1, -1), 1.5),
("LINEABOVE", (0, -1), (1, -1), 0.8, ACCENT),
("LINEBELOW", (0, -1), (1, -1), 0.8, ACCENT),
("TOPPADDING", (0, -1), (-1, -1), 4),
]
summen = Table(summen_rows, colWidths=[CONTENT_W - 44 * mm, 44 * mm])
summen.setStyle(TableStyle(style_cmds))
story.append(summen)
# Hinweis zur Beherbergungssteuer.
if angebot:
story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph(
"In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt "
"werden sie erst in der Rechnung nach dem Aufenthalt.", st["dank"]))
else:
for a in rechnung.aufschlaege:
if not a.bezeichnung:
continue
satz_txt = eur_kurz(a.satz).replace(",00", "")
story.append(Spacer(1, 1.5 * mm))
if art == "storno":
# Auf dem Storno wird nichts erhoben, sondern zurueckgenommen.
story.append(Paragraph(
f"Die mit der stornierten Rechnung erhobene {a.bezeichnung} "
f"({satz_txt} %) wird mit aufgehoben.", st["dank"]))
else:
story.append(Paragraph(
f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die "
f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"]))
story.append(Spacer(1, 8 * mm))
# ---- Schlusstexte -------------------------------------------------------
texte = cfg.get("texte", {})
block = []
if art == "storno":
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
# nichts geschuldet, sondern aufgehoben.
bezug = kopf.get("storno_zu", "")
if kopf.get("storno_datum"):
bezug += f" vom {kopf['storno_datum']}"
folgt = kopf.get("korrektur_nummer") or ""
if kopf.get("ersatz_vorhanden") and folgt:
# Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
# bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
# Gast auf eine Rechnung, die nie kommt.
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
"weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
"verrechnet.", st["dank"]))
story.append(KeepTogether(block))
return
if kopf.get("nur_storno"):
# Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet.
# Hier darf NICHT von einer berichtigten Rechnung die Rede sein -
# es kommt keine, und der Gast wartet sonst darauf.
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Die Leistung wird nicht abgerechnet – es folgt keine weitere "
"Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"]))
story.append(KeepTogether(block))
return
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
f"→ {folgt}).") if folgt else ""
block.append(Paragraph(
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
"Rechnung verrechnet." + kette, st["dank"]))
elif art == "berichtigung":
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
# Aussteller. Also bleibt der Satz drauf.
block.append(Paragraph(
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
"ihre Nummer.", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
elif angebot:
# Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
# noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen.
block.append(Paragraph(
"Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und "
"keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren "
"wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen "
"stehen wir gern zur Verfügung.",
st["dank"]))
else:
if art == "korrektur" and kopf.get("ersetzt"):
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
if kopf.get("storno_nummer"):
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
block.append(Paragraph(hinweis + ".", st["dank"]))
if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"]))
if texte.get("dank2"):
block.append(Paragraph(texte["dank2"], st["dank"]))
if texte.get("hinweis"):
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
story.append(KeepTogether(block))
def _kenndaten(rechnung, kopf):
"""
Kenndaten der Rechnung als JSON in die PDF-Metadaten (Feld "Subject").
Das Steuerjournal (Programm "Beherbergungssteuer") liest sie von dort aus,
statt Nachname und Betraege aus dem Fliesstext zu raten. Fuer den Ausdruck
aendert sich nichts - Metadaten stehen nicht auf dem Blatt.
Version 2 ergaenzt die USt-Aufteilung ("ust_bloecke") fuers dritte Programm,
den Steuerrechner: der braucht Brutto->Netto je Steuersatz, und 7 % und 19 %
stecken auf einer Rechnung gemischt drin. Alle Felder der Version 1 bleiben
unveraendert stehen - am "zwischensumme"/"steuer_satz"/"steuer_betrag" haengt
der Import des Steuerjournals, das darf nicht brechen.
Version 3 kommt mit dem Storno dazu:
"art" - "rechnung", "storno" oder "korrektur"
"storno_zu" - beim Storno die Nummer der aufgehobenen Rechnung
"ersetzt" - bei der Korrektur die Nummer der falschen Rechnung
"kunde" / "positionen" / "anreise" / "abreise" - der komplette Formularstand,
damit eine Rechnung spaeter wieder ins Formular geladen werden kann, statt
sie abzutippen. Aeltere PDFs (v1/v2) haben das nicht.
Beim Storno sind alle Betraege NEGATIV - das Steuerjournal und der
Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120).
"""
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
if kopf.get("art") == "angebot":
# Ein Angebot ist kein Umsatz - es traegt Nullen. Ein Programm, das
# "art" nicht kennt, zaehlt dann 0,00 statt einer Uebernachtung, die nie
# abgerechnet wurde.
return json.dumps({
"quelle": "rechnungstool", "version": 3, "art": "angebot",
"rechnungsnummer": kopf.get("rechnungsnummer", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""),
"naechte": kopf.get("naechte", ""),
"zwischensumme": 0.0, "gesamt": 0.0,
"steuer_satz": 0.0, "steuer_betrag": 0.0,
"netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [],
"kunde": kopf.get("kunde", {}),
"anreise": kopf.get("anreise", ""), "abreise": kopf.get("abreise", ""),
}, ensure_ascii=False)
if kopf.get("art") == "berichtigung":
# Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber
# KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art"
# nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal.
return json.dumps({
"quelle": "rechnungstool", "version": 3, "art": "berichtigung",
"vorgang": kopf.get("vorgang") or kopf.get("berichtigt_zu", ""),
"rechnungsnummer": kopf.get("berichtigt_zu", ""),
"berichtigt_zu": kopf.get("berichtigt_zu", ""),
"berichtigt_am": kopf.get("berichtigt_am", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""),
"zwischensumme": 0.0, "gesamt": 0.0,
"steuer_satz": 0.0, "steuer_betrag": 0.0,
"netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [],
"kunde": kopf.get("kunde", {}),
}, ensure_ascii=False)
daten = {
"quelle": "rechnungstool",
"version": 3,
"rechnungsnummer": kopf.get("rechnungsnummer", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""),
"vorname": kopf.get("vorname", ""),
"naechte": kopf.get("naechte", ""),
"zwischensumme": runde(rechnung.brutto),
"steuer_satz": float(aufschlag.satz) if aufschlag else 0.0,
"steuer_betrag": runde(aufschlag.betrag) if aufschlag else 0.0,
"gesamt": runde(rechnung.gesamt),
# neu in Version 2: je USt-Satz ein Block (Netto, enthaltene USt, Brutto)
"netto_gesamt": runde(rechnung.netto_gesamt),
"ust_gesamt": runde(rechnung.ust_gesamt),
"ust_bloecke": [
{"satz": b.satz, "netto": runde(b.netto), "ust": runde(b.ust),
"brutto": runde(b.brutto)}
for b in rechnung.bloecke
],
# neu in Version 3
"art": kopf.get("art", "rechnung"),
# Klammer um die ganze Kette: Rechnung, ihr Storno, die berichtigte
# Rechnung und jede Berichtigung tragen DIESELBE Vorgangsnummer - die der
# allerersten Rechnung. Ein anderes Programm gruppiert danach in einem
# Schritt, statt sich die Kette ueber storno_zu/ersetzt zusammenzusuchen.
# Eine normale Rechnung ist ihr eigener Vorgang.
"vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""),
"storno_zu": kopf.get("storno_zu", ""),
"korrektur_nummer": kopf.get("korrektur_nummer", ""),
"ersatz_vorhanden": bool(kopf.get("ersatz_vorhanden")),
"nur_storno": bool(kopf.get("nur_storno")),
"storno_datum": kopf.get("storno_datum", ""),
"ersetzt": kopf.get("ersetzt", ""),
"storno_nummer": kopf.get("storno_nummer", ""),
"anreise": kopf.get("anreise", ""),
"abreise": kopf.get("abreise", ""),
"kunde": kopf.get("kunde", {}),
"positionen": kopf.get("positionen", []),
}
# Montage-Rechnung: welches Formular sie geschrieben hat und der Text der
# einen Zeile. "art" bleibt "rechnung" - fuer das Steuerjournal ist sie eine
# ganz normale Buchung (Miete mit Beherbergungssteuer), nur eben monatsweise.
# Damit kann das Formular sie spaeter wieder laden und der Storno-Weg
# erkennt, in welchen Reiter sie gehoert.
if kopf.get("vorlage"):
daten["vorlage"] = kopf["vorlage"]
daten["montage_text"] = kopf.get("montage_text", "")
return json.dumps(daten, ensure_ascii=False)
def render_rechnung(pfad, cfg, rechnung, kopf, vorschau=False):
"""
pfad : Zielpfad der PDF
cfg : geladene Konfiguration
rechnung : berechnung.Rechnung
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
EIN Blatt: die Rechnung fuer den Gast.
Bis zum 05.09.2026 hing hier ein zweites Blatt "fuer das Amt" mit derselben
Rechnungsnummer, aber ohne Beherbergungssteuer - und damit mit einem anderen
Gesamtbetrag. Genau das ist bei einer Pruefung ein Problem: mehrere Blaetter
zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber
verschiedene Umsaetze abgerechnet. Es ist ersatzlos entfallen.
Was die Stadt braucht, kommt aus dem Steuerjournal: es liest die Kenndaten
(subject) dieser PDF und rechnet die Bemessungsgrundlage selbst aus. Die
Anmeldung geht als Summenmeldung ans Amt, nicht als Rechnungskopie mit
Gastnamen.
"""
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
st = _styles()
doc = SimpleDocTemplate(
pfad, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=({"storno": "Stornorechnung",
"angebot": "Angebot"}.get(kopf.get("art"), "Rechnung"))
+ f" {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""),
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
)
story = []
_seite_inhalt(story, st, cfg, rechnung, kopf)
def seite(c, d):
_kopf_fuss(c, d, cfg, vorschau=vorschau)
doc.build(story, onFirstPage=seite, onLaterPages=seite)
return pfad