Compare commits

...

3 commits

Author SHA1 Message Date
TheMockTv
ad1ac9af6f Angebotsnummer statt Rechnungsnummer, und Angebote in einen eigenen Unterordner
"und rechnungsnummer ist dann angebotsnummer - dann ist das auch weg": das Feld
heisst jetzt Angebotsnummer, und der Hinweis "die Nummer oben ist keine
Rechnungsnummer" konnte damit entfallen. Auf dem ganzen Blatt steht das Wort
Rechnung nur noch im Pflichthinweis, der gerade sagt, dass es keine ist. Der
Pruefstand prueft beides ausdruecklich.

"oder in ein extra ordner gepackt werden": die Angebote landen in "Angebote"
neben den Rechnungen. Der Rechnungsordner ist die Buchhaltung, dort haben sie
nichts zu suchen. Weggeworfen werden duerfen sie trotzdem nicht - ein
abgesandtes Angebot ist ein Handelsbrief (§ 257 HGB) - deshalb ein Unterordner
und kein anderer Ort. Die Nummernsuche schaut jetzt dort nach, und das
Steuerjournal ueberspringt sie ohnehin ueber art="angebot".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
2026-09-03 21:39:43 +02:00
TheMockTv
82d84f7aea Aus der Proforma-Rechnung wird ein unverbindliches Angebot
Seine Frage war: "was ist rechtssicher, kostenvoranschlag oder proforma - ich
bin kein jurist, deswegen sage ich ja § schauen". Nachgesehen:

* Kostenanschlag ist ein Begriff des WERKvertrags (§ 632 Abs. 3 BGB "Ein
  Kostenanschlag ist im Zweifel nicht zu verguten", § 649 BGB) - das ist der
  Handwerker-Fall. Bartl vermietet, das passt nicht.
* "Proforma-Rechnung" steht in keinem Gesetz (Zoll- und Exportbegriff) und
  traegt als Einziges das Wort "Rechnung".
* Das Angebot ist § 145 BGB: gebunden ist man daran, "es sei denn, dass er die
  Gebundenheit ausgeschlossen hat" - deshalb steht "unverbindlich" drauf.
* Im Camping- und Hotelgeschaeft ist "unverbindliches Angebot" der uebliche
  Begriff (auch in den Muster-AGB des Branchenverbands BVCD).

Seine Entscheidung: "dann mach angebot draus, ist das gaengigste" - Begruendung
"dann ist der name rechnung weg und erweckt nicht den anschein das man eine
bekommen hat".

Deshalb heisst es jetzt ueberall Angebot: Ueberschrift "Unverbindliches
Angebot", "Angebot an:", "Angebot-Nr.: 2026-A001" und "Datum:" statt
"Rechnungsdatum:" - das Wort Rechnung kommt im ganzen Kopf nicht mehr vor, und
genau das prueft pruef_angebot.py auch nach. Eigene Reihe 2026-A001, Dateiname
Angebot_..., eigener Zaehler (angebot_jahr/angebot_zaehler).

Steuerlich aendert der Name nichts (§ 14 Abs. 1 S. 1 UStG: die Bezeichnung des
Dokuments ist gleichgueltig) - die Sicherheit kommt weiter vom fehlenden
Steuerausweis, siehe UStAE 14c.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
2026-09-03 21:37:32 +02:00
TheMockTv
c190887621 Proforma-Blatt heisst auch Kostenvoranschlag - das ist sein Zweck
Seine Erklaerung: 'es geht nur darum das er seinen kunden wie eine art
kostenvoranschlag/proforma rechnung das die wissen ah das kommt auf mich zu und
wenn die ja sagen er dann die richtige machen tut'. Ueberschrift jetzt
'Proforma-Rechnung (Kostenvoranschlag)', im Text 'Dies ist ein
Kostenvoranschlag' - damit steht die Natur des Blattes ganz vorn und nicht das
Wort Rechnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
2026-09-03 21:27:20 +02:00
4 changed files with 124 additions and 96 deletions

84
app.py
View file

@ -839,7 +839,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
for name, wert in felder if wert and not parse_date(wert)] for name, wert in felder if wert and not parse_date(wert)]
def _art_waehlen(self, nummer, betrag, wer): def _art_waehlen(self, nummer, betrag, wer):
"""Popup: Rechnung oder Proforma? Gibt "rechnung", "proforma" oder None. """Popup: Rechnung oder Angebot? Gibt "rechnung", "angebot" oder None.
Ersetzt die fruehere Sicherheitsfrage - EIN Fenster statt zwei. Die Ersetzt die fruehere Sicherheitsfrage - EIN Fenster statt zwei. Die
Warnung, dass eine ausgestellte Rechnung nicht mehr zu aendern ist, Warnung, dass eine ausgestellte Rechnung nicht mehr zu aendern ist,
@ -850,7 +850,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# setzt _dialog_zeigen und drueckt die Knoepfe wirklich. # setzt _dialog_zeigen und drueckt die Knoepfe wirklich.
if TESTLAUF and not getattr(self, "_dialog_zeigen", False): if TESTLAUF and not getattr(self, "_dialog_zeigen", False):
# _testwahl: was der Pruefstand im Fenster geklickt haette. # _testwahl: was der Pruefstand im Fenster geklickt haette.
# "rechnung" (Vorgabe), "proforma" oder None fuer Abbrechen. # "rechnung" (Vorgabe), "angebot" oder None fuer Abbrechen.
return getattr(self, "_testwahl", "rechnung") return getattr(self, "_testwahl", "rechnung")
d = tk.Toplevel(self) d = tk.Toplevel(self)
d.title("Was soll erstellt werden?") d.title("Was soll erstellt werden?")
@ -871,9 +871,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
"bucht sie und meldet die Übernachtungen ans Amt.")).grid( "bucht sie und meldet die Übernachtungen ans Amt.")).grid(
row=1, column=0, sticky="w", pady=(10, 8)) row=1, column=0, sticky="w", pady=(10, 8))
ttk.Label(rahmen, wraplength=560, justify="left", text=( ttk.Label(rahmen, wraplength=560, justify="left", text=(
"PROFORMA-RECHNUNG – unverbindliche Kostenaufstellung vorab. Keine " "ANGEBOT – unverbindliche Kostenaufstellung vorab (§ 145 BGB). Keine "
"Rechnung im Sinne des § 14 UStG, kein Vorsteuerabzug, nicht zu zahlen. " "Rechnung im Sinne des § 14 UStG, kein Vorsteuerabzug, nicht zu zahlen. "
"Sie verbraucht KEINE Rechnungsnummer (eigene Reihe) und wird nirgends " "Es verbraucht KEINE Rechnungsnummer (eigene Reihe) und wird nirgends "
f"gebucht – die nächste Rechnung bleibt {nummer}.")).grid( f"gebucht – die nächste Rechnung bleibt {nummer}.")).grid(
row=2, column=0, sticky="w", pady=(0, 14)) row=2, column=0, sticky="w", pady=(0, 14))
@ -885,8 +885,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
knoepfe.grid(row=3, column=0, sticky="ew") knoepfe.grid(row=3, column=0, sticky="ew")
ttk.Button(knoepfe, text=f"Rechnung {nummer} erstellen", width=28, ttk.Button(knoepfe, text=f"Rechnung {nummer} erstellen", width=28,
command=lambda: nimm("rechnung")).pack(side="left") command=lambda: nimm("rechnung")).pack(side="left")
ttk.Button(knoepfe, text="Proforma-Rechnung", width=20, ttk.Button(knoepfe, text="Angebot erstellen", width=20,
command=lambda: nimm("proforma")).pack(side="left", padx=8) command=lambda: nimm("angebot")).pack(side="left", padx=8)
ttk.Button(knoepfe, text="Abbrechen", width=14, ttk.Button(knoepfe, text="Abbrechen", width=14,
command=d.destroy).pack(side="right") command=d.destroy).pack(side="right")
@ -899,30 +899,46 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
self.wait_window(d) self.wait_window(d)
return wahl["art"] return wahl["art"]
def _naechste_proforma_nummer(self): def _angebot_ordner(self):
"""JJJJ-Pnnn aus der EIGENEN Reihe. Der Jahreswechsel faengt bei P001 an. """Unterordner "Angebote" neben den Rechnungen.
Wichtig: hier wird der Rechnungszaehler nicht angefasst. Eine Proforma Angebote sind keine Buchhaltung - im Rechnungsordner haetten sie nur
zwischen den Belegen gelegen. Aufzubewahren sind sie trotzdem: ein
abgesandtes Angebot ist ein Handelsbrief (§ 257 HGB). Das Steuerjournal
liest den Ordner mit, ueberspringt sie aber (art="angebot").
"""
ordner = os.path.join(config.ensure_output_dir(self.cfg), "Angebote")
try:
os.makedirs(ordner, exist_ok=True)
except OSError as e: # noqa: BLE001 - dann eben neben die Rechnungen
print("[angebot] Unterordner nicht anlegbar:", e)
return config.ensure_output_dir(self.cfg)
return ordner
def _naechstes_angebot(self):
"""JJJJ-Annn aus der EIGENEN Reihe. Der Jahreswechsel faengt bei A001 an.
Wichtig: hier wird der Rechnungszaehler nicht angefasst. Ein Angebot
darf keine Rechnungsnummer verbrauchen - sonst muesste man die Nummern darf keine Rechnungsnummer verbrauchen - sonst muesste man die Nummern
wieder von Hand aendern koennen, und genau daher kamen die doppelten. wieder von Hand aendern koennen, und genau daher kamen die doppelten.
""" """
jahr = datetime.date.today().year jahr = datetime.date.today().year
zaehler = int(self.cfg.get("proforma_zaehler", 0) or 0) zaehler = int(self.cfg.get("angebot_zaehler", 0) or 0)
if int(self.cfg.get("proforma_jahr", 0) or 0) != jahr: if int(self.cfg.get("angebot_jahr", 0) or 0) != jahr:
zaehler = 0 zaehler = 0
out_dir = config.ensure_output_dir(self.cfg) ordner = self._angebot_ordner()
try: try:
vorhanden = os.listdir(out_dir) vorhanden = os.listdir(ordner)
except OSError: except OSError:
vorhanden = [] vorhanden = []
for _ in range(1000): for _ in range(1000):
zaehler += 1 zaehler += 1
nummer = f"{jahr}-P{zaehler:03d}" nummer = f"{jahr}-A{zaehler:03d}"
if not any(d.startswith(f"Proforma_{nummer}") for d in vorhanden): if not any(d.startswith(f"Angebot_{nummer}") for d in vorhanden):
return jahr, zaehler, nummer return jahr, zaehler, nummer
return jahr, zaehler, f"{jahr}-P{zaehler:03d}" return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
def pdf_erstellen(self, proforma=False): def pdf_erstellen(self, angebot=False):
montage = self.ist_montage() montage = self.ist_montage()
r = self.recalc() r = self.recalc()
if not r.zeilen: if not r.zeilen:
@ -987,11 +1003,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
wahl = self._art_waehlen(nummer, berechnung.eur(r.gesamt), wer) wahl = self._art_waehlen(nummer, berechnung.eur(r.gesamt), wer)
if wahl is None: if wahl is None:
return return
proforma = wahl == "proforma" angebot = wahl == "angebot"
if proforma: if angebot:
# EIGENE Reihe: Nummernfeld und Rechnungszaehler bleiben # EIGENE Reihe: Nummernfeld und Rechnungszaehler bleiben
# unangetastet, die naechste Rechnung behaelt ihre Nummer. # unangetastet, die naechste Rechnung behaelt ihre Nummer.
p_jahr, p_zaehler, nummer = self._naechste_proforma_nummer() a_jahr, a_zaehler, nummer = self._naechstes_angebot()
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile; # Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn # der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn
@ -1044,8 +1060,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer. # dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer.
kopf["vorlage"] = "montage" kopf["vorlage"] = "montage"
kopf["montage_text"] = self._montage_text() kopf["montage_text"] = self._montage_text()
if proforma: if angebot:
kopf["art"] = "proforma" kopf["art"] = "angebot"
kopf["rechnungsnummer"] = nummer kopf["rechnungsnummer"] = nummer
if stand: if stand:
kopf["art"] = "korrektur" kopf["art"] = "korrektur"
@ -1140,32 +1156,32 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
self.r_nummer.set(nummer) self.r_nummer.set(nummer)
kopf["rechnungsnummer"] = nummer kopf["rechnungsnummer"] = nummer
if proforma: if angebot:
name = f"Proforma_{safe_filename(nummer)}" name = f"Angebot_{safe_filename(nummer)}"
if self.k_name.get().strip(): if self.k_name.get().strip():
name += "_" + safe_filename(self.k_name.get().strip()) name += "_" + safe_filename(self.k_name.get().strip())
pfad = os.path.join(out_dir, name + ".pdf") pfad = os.path.join(self._angebot_ordner(), name + ".pdf")
try: try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf) pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
messagebox.showerror( messagebox.showerror(
"Fehler beim Erstellen", "Fehler beim Erstellen",
f"Proforma konnte nicht erstellt werden:\n{e}") f"Angebot konnte nicht erstellt werden:\n{e}")
print("[proforma] FEHLER:", e) print("[angebot] FEHLER:", e)
return return
# NUR der Proforma-Zaehler wandert weiter. Rechnungsnummer, deren # NUR der Angebots-Zaehler wandert weiter. Rechnungsnummer, deren
# Zaehler, das gemeinsame Nummernbuch und die Meldung ans # Zaehler, das gemeinsame Nummernbuch und die Meldung ans
# Steuerjournal bleiben unberuehrt - gebucht wird eine Proforma # Steuerjournal bleiben unberuehrt - gebucht wird ein Angebot
# nirgends. # nirgends.
self.cfg["proforma_jahr"] = p_jahr self.cfg["angebot_jahr"] = a_jahr
self.cfg["proforma_zaehler"] = p_zaehler self.cfg["angebot_zaehler"] = a_zaehler
try: try:
config.save_config(self.cfg) config.save_config(self.cfg)
except OSError as e: # noqa: BLE001 except OSError as e: # noqa: BLE001
print("[proforma] Zaehler nicht gespeichert:", e) print("[angebot] Zaehler nicht gespeichert:", e)
print(f"[proforma] erstellt -> {pfad}") print(f"[angebot] erstellt -> {pfad}")
if messagebox.askyesno( if messagebox.askyesno(
"Fertig", f"Proforma-Rechnung erstellt:\n{pfad}\n\n" "Fertig", f"Angebot erstellt:\n{pfad}\n\n"
"Jetzt öffnen (zum Drucken)?"): "Jetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad) self._oeffne_datei(pfad)
return return

View file

@ -140,10 +140,10 @@ DEFAULT_CONFIG = {
"texte": DEFAULT_TEXTE, "texte": DEFAULT_TEXTE,
"katalog": DEFAULT_KATALOG, "katalog": DEFAULT_KATALOG,
"montage": DEFAULT_MONTAGE, "montage": DEFAULT_MONTAGE,
# Die Proforma hat eine EIGENE Reihe (2026-P001): sie darf keine # Das Angebot hat eine EIGENE Reihe (2026-A001): es darf keine
# Rechnungsnummer verbrauchen - die gehoert zu einer echten Rechnung. # Rechnungsnummer verbrauchen - die gehoert zu einer echten Rechnung.
"proforma_jahr": 0, "angebot_jahr": 0,
"proforma_zaehler": 0, "angebot_zaehler": 0,
# Alle Steuern als EINE benannte Liste. # Alle Steuern als EINE benannte Liste.
# - extra_blatt = False: USt-Satz, wird auf das Netto der zugeordneten Leistungen gerechnet. # - extra_blatt = False: USt-Satz, wird auf das Netto der zugeordneten Leistungen gerechnet.
# - extra_blatt = True : Aufschlag auf die Endsumme (z.B. Beherbergungssteuer), eigenes Blatt. # - extra_blatt = True : Aufschlag auf die Endsumme (z.B. Beherbergungssteuer), eigenes Blatt.

View file

@ -278,7 +278,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
story.append(Spacer(1, 5 * mm)) story.append(Spacer(1, 5 * mm))
# Kundenadresse (links) | Rechnungsinfos (rechts) # Kundenadresse (links) | Rechnungsinfos (rechts)
kunde_flow = [Paragraph("Proforma an:" if kopf.get("art") == "proforma" kunde_flow = [Paragraph("Angebot an:" if kopf.get("art") == "angebot"
else "Rechnung an:", st["addr_label"])] else "Rechnung an:", st["addr_label"])]
for z in kopf.get("kunde_zeilen", []): for z in kopf.get("kunde_zeilen", []):
if str(z).strip(): if str(z).strip():
@ -291,12 +291,14 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# Keine Positionstabelle, keine Naechte im Kopf - der Zeitraum steht in der # Keine Positionstabelle, keine Naechte im Kopf - der Zeitraum steht in der
# einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat. # einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat.
montage = kopf.get("vorlage") == "montage" montage = kopf.get("vorlage") == "montage"
proforma = art == "proforma" angebot = art == "angebot"
info_rows = [ info_rows = [
("Stornonummer:" if art == "storno" else ("Stornonummer:" if art == "storno" else
"Proforma-Nr.:" if proforma else "Rechnungsnummer:", "Angebotsnummer:" if angebot else "Rechnungsnummer:",
kopf.get("rechnungsnummer", "")), kopf.get("rechnungsnummer", "")),
("Rechnungsdatum:", kopf.get("datum", "")), # 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. # Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum.
if art == "storno" and kopf.get("storno_zu"): if art == "storno" and kopf.get("storno_zu"):
@ -360,39 +362,38 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# eindeutig als Aufhebung erkennbar ist. # eindeutig als Aufhebung erkennbar ist.
titel = {"storno": "Stornorechnung", titel = {"storno": "Stornorechnung",
"berichtigung": "Berichtigte Rechnung", "berichtigung": "Berichtigte Rechnung",
"proforma": "Proforma-Rechnung"}.get(art, "Rechnungsübersicht") "angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht")
titel += " (für das Amt)" if gesamtrechnung_betont else "" titel += " (für das Amt)" if gesamtrechnung_betont else ""
story.append(Paragraph(titel, st["h"])) story.append(Paragraph(titel, st["h"]))
if proforma: if angebot:
# Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine - # Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine -
# und ohne die deutliche Kennzeichnung kann das Finanzamt es als eine # und ohne die deutliche Kennzeichnung kann das Finanzamt es als eine
# werten. Ein Steuersatz oder Steuerbetrag steht bewusst NIRGENDS auf # werten. Ein Steuersatz oder Steuerbetrag steht bewusst NIRGENDS auf
# dem Blatt: wer Steuer ausweist, schuldet sie (§ 14c UStG). # dem Blatt: wer Steuer ausweist, schuldet sie (§ 14c UStG).
story.append(Paragraph( story.append(Paragraph(
"<b>Dies ist eine Proforma-Rechnung</b> – eine unverbindliche Vorab-" "<b>Dies ist ein unverbindliches Angebot</b> und <b>keine Rechnung im "
"Aufstellung der Kosten. Sie ist <b>keine Rechnung im Sinne des § 14 " "Sinne des § 14 UStG</b>: es berechtigt nicht zum Vorsteuerabzug und ist "
"UStG</b>, berechtigt nicht zum Vorsteuerabzug und ist nicht zu bezahlen. " "nicht zu bezahlen. Alle Beträge in Euro inkl. gesetzlicher "
"Die Nummer oben ist <b>keine Rechnungsnummer</b>. Alle Beträge in Euro " "Umsatzsteuer; ausgewiesen wird "
"inkl. gesetzlicher Umsatzsteuer; ausgewiesen wird sie erst in der " "sie erst in der Rechnung nach dem Aufenthalt.", st["dank"]))
"Rechnung nach dem Aufenthalt.", st["dank"]))
story.append(Spacer(1, 3 * mm)) story.append(Spacer(1, 3 * mm))
if montage: if montage:
_montage_zeile(story, st, rechnung, kopf) _montage_zeile(story, st, rechnung, kopf)
else: else:
_positionstabelle(story, st, rechnung, ohne_ust=proforma) _positionstabelle(story, st, rechnung, ohne_ust=angebot)
story.append(Spacer(1, 3 * mm)) story.append(Spacer(1, 3 * mm))
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto) # ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
# -> zzgl. Beherbergungssteuer -> Gesamtbetrag ---- # -> zzgl. Beherbergungssteuer -> Gesamtbetrag ----
# Auf der Proforma steht weder Netto noch Umsatzsteuer: ausgewiesen wird # Auf dem Angebot steht weder Netto noch Umsatzsteuer: ausgewiesen wird
# sie erst mit der Rechnung. Die Beherbergungssteuer ist keine Umsatzsteuer # sie erst mit der Rechnung. Die Beherbergungssteuer ist keine Umsatzsteuer
# und darf bleiben - der Gast soll sehen, was er zahlen wird. # und darf bleiben - der Gast soll sehen, was er zahlen wird.
summen_rows = [] if proforma else [[ summen_rows = [] if angebot else [[
Paragraph("Nettobetrag:", st["info_l"]), Paragraph("Nettobetrag:", st["info_l"]),
Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]), Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]),
]] ]]
for b in ([] if proforma else rechnung.bloecke): for b in ([] if angebot else rechnung.bloecke):
name = b.bezeichnung or "USt" name = b.bezeichnung or "USt"
summen_rows.append([ summen_rows.append([
Paragraph(f"{name} {b.satz} %:", st["info_l"]), Paragraph(f"{name} {b.satz} %:", st["info_l"]),
@ -408,10 +409,10 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# #
# Auf der Kundenseite bleibt alles wie gehabt - dort MUSS die Steuer ausgewiesen # Auf der Kundenseite bleibt alles wie gehabt - dort MUSS die Steuer ausgewiesen
# sein und im Gesamtbetrag stecken, das ist der Betrag, den der Gast zahlt. # sein und im Gesamtbetrag stecken, das ist der Betrag, den der Gast zahlt.
# Auf der Proforma wird gar keine Steuer ausgewiesen - weder Umsatzsteuer # Auf dem Angebot wird gar keine Steuer ausgewiesen - weder Umsatzsteuer
# noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich # noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich
# zahlt; aufgeschluesselt wird erst in der Rechnung. # zahlt; aufgeschluesselt wird erst in der Rechnung.
if rechnung.aufschlaege and not gesamtrechnung_betont and not proforma: if rechnung.aufschlaege and not gesamtrechnung_betont and not angebot:
summen_rows.append([ summen_rows.append([
Paragraph("Zwischensumme:", st["info_l"]), Paragraph("Zwischensumme:", st["info_l"]),
Paragraph(eur(rechnung.brutto), st["cell_r"]), Paragraph(eur(rechnung.brutto), st["cell_r"]),
@ -425,7 +426,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt
summen_rows.append([ summen_rows.append([
Paragraph("Voraussichtlicher Gesamtbetrag:" if proforma else Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else
"Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", "Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:",
st["total_l"]), st["total_l"]),
Paragraph(eur(endsumme), st["total_v"]), Paragraph(eur(endsumme), st["total_v"]),
@ -444,7 +445,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt # Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt
# wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos. # wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos.
if proforma: if angebot:
story.append(Spacer(1, 1.5 * mm)) story.append(Spacer(1, 1.5 * mm))
story.append(Paragraph( story.append(Paragraph(
"In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt " "In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt "
@ -501,14 +502,15 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
"ihre Nummer.", st["dank"])) "ihre Nummer.", st["dank"]))
if texte.get("dank1"): if texte.get("dank1"):
block.append(Paragraph(texte["dank1"], st["dank"])) block.append(Paragraph(texte["dank1"], st["dank"]))
elif proforma: elif angebot:
# Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt # Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
# noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die # noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen. # Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen.
block.append(Paragraph( block.append(Paragraph(
"Diese Aufstellung ist unverbindlich und dient nur zur Information. " "Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und "
"Sie ist keine Zahlungsaufforderung – die Rechnung erhalten Sie nach " "keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren "
"dem Aufenthalt. Für Rückfragen stehen wir gern zur Verfügung.", "wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen "
"stehen wir gern zur Verfügung.",
st["dank"])) st["dank"]))
else: else:
if art == "korrektur" and kopf.get("ersetzt"): if art == "korrektur" and kopf.get("ersetzt"):
@ -550,12 +552,12 @@ def _kenndaten(rechnung, kopf):
Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120). Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120).
""" """
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
if kopf.get("art") == "proforma": if kopf.get("art") == "angebot":
# Eine Proforma ist kein Umsatz - sie traegt Nullen. Ein Programm, das # Ein Angebot ist kein Umsatz - es traegt Nullen. Ein Programm, das
# "art" nicht kennt, zaehlt dann 0,00 statt einer Uebernachtung, die nie # "art" nicht kennt, zaehlt dann 0,00 statt einer Uebernachtung, die nie
# abgerechnet wurde. # abgerechnet wurde.
return json.dumps({ return json.dumps({
"quelle": "rechnungstool", "version": 3, "art": "proforma", "quelle": "rechnungstool", "version": 3, "art": "angebot",
"rechnungsnummer": kopf.get("rechnungsnummer", ""), "rechnungsnummer": kopf.get("rechnungsnummer", ""),
"datum": kopf.get("datum", ""), "datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""), "nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""),
@ -652,16 +654,16 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
leftMargin=MARGIN_L, rightMargin=MARGIN_R, leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=({"storno": "Stornorechnung", title=({"storno": "Stornorechnung",
"proforma": "Proforma-Rechnung"}.get(kopf.get("art"), "Rechnung")) "angebot": "Angebot"}.get(kopf.get("art"), "Rechnung"))
+ f" {kopf.get('rechnungsnummer','')}", + f" {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""), author=cfg["firma"].get("name", ""),
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
) )
story = [] story = []
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Kunde _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Kunde
# Eine Proforma bekommt KEIN Blatt fuers Amt: gemeldet wird eine # Ein Angebot bekommt KEIN Blatt fuers Amt: gemeldet wird eine
# Uebernachtung erst, wenn sie abgerechnet ist. # Uebernachtung erst, wenn sie abgerechnet ist.
if rechnung.aufschlaege and kopf.get("art") != "proforma": if rechnung.aufschlaege and kopf.get("art") != "angebot":
story.append(PageBreak()) story.append(PageBreak())
story.append(_AmtMarke(marke)) story.append(_AmtMarke(marke))
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt

View file

@ -1,21 +1,23 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Pruefstand: die Proforma-Rechnung. """Pruefstand: das unverbindliche Angebot.
Sein Auftrag: ein Knopf, im Popup waehlt man Rechnung oder Proforma - *"musst Sein Auftrag: ein Knopf, im Popup waehlt man Rechnung oder Angebot - *"musst
nur auf passen das der rechner dann die rechnungs nummer nicht aendert, sonst nur auf passen das der rechner dann die rechnungs nummer nicht aendert, sonst
muessen wir wieder die nummern aenderbar machen und dann macht er wieder muessen wir wieder die nummern aenderbar machen und dann macht er wieder
fehler"*. fehler"*.
Genau das ist hier die Hauptpruefung: die Proforma laeuft in einer EIGENEN Genau das ist hier die Hauptpruefung: das Angebot laeuft in einer EIGENEN
Reihe (2026-P001) und laesst Nummernfeld, Zaehler und Nummernbuch unberuehrt. Reihe (2026-A001) und laesst Nummernfeld, Zaehler und Nummernbuch unberuehrt.
Dazu die Rechtslage (nachgeschlagen am 03.09.2026): Dazu die Rechtslage (nachgeschlagen am 03.09.2026):
* keine Nummer aus dem regulaeren Kreis - die fortlaufende Nummerierung nach * keine Nummer aus dem regulaeren Kreis - die fortlaufende Nummerierung nach
§ 14 Abs. 4 Nr. 4 UStG gilt nur fuer echte Rechnungen § 14 Abs. 4 Nr. 4 UStG gilt nur fuer echte Rechnungen
* kein Steuersatz und kein Steuerbetrag auf dem Blatt (§ 14c UStG: wer Steuer * kein Steuersatz und kein Steuerbetrag auf dem Blatt (§ 14c UStG: wer Steuer
ausweist, schuldet sie) ausweist, schuldet sie)
* deutlich als "Proforma-Rechnung" kennzeichnen, sonst kann das Finanzamt das * deutlich kennzeichnen, sonst kann das Finanzamt das Blatt als echte Rechnung
Blatt als echte Rechnung werten werten (§ 14 Abs. 1 S. 1 UStG: die Bezeichnung ist gleichgueltig). "Angebot"
ist im Camping- und Hotelgeschaeft der uebliche Begriff, "Kostenanschlag"
gehoert ins Werkvertragsrecht (§ 632 Abs. 3, § 649 BGB).
Gefahren wird das ECHTE Popup - die Knoepfe werden angeklickt, nicht umgangen. Gefahren wird das ECHTE Popup - die Knoepfe werden angeklickt, nicht umgangen.
""" """
@ -51,7 +53,7 @@ _wecker.daemon = True
_wecker.start() _wecker.start()
os.environ["CAMPINGHOF_TEST"] = "1" os.environ["CAMPINGHOF_TEST"] = "1"
tmp = tempfile.mkdtemp(prefix="proforma_pruef_") tmp = tempfile.mkdtemp(prefix="angebot_pruef_")
ordner = os.path.join(tmp, "rechnungen") ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner) os.makedirs(ordner)
@ -132,8 +134,8 @@ def kunde(vorname, nachname):
# ---- 1. Der eine Knopf und das Popup -------------------------------------- # ---- 1. Der eine Knopf und das Popup --------------------------------------
knopf_texte = [str(k.cget("text")) for k in knoepfe_von(f)] knopf_texte = [str(k.cget("text")) for k in knoepfe_von(f)]
pruefe("PDF erstellen" in knopf_texte, f"unten steht EIN Knopf zum Erstellen ({knopf_texte})") pruefe("PDF erstellen" in knopf_texte, f"unten steht EIN Knopf zum Erstellen ({knopf_texte})")
pruefe(not any("Proforma" in t for t in knopf_texte), pruefe(not any("Angebot" in t for t in knopf_texte),
"und kein zweiter Knopf fuer die Proforma - gewaehlt wird im Popup") "und kein zweiter Knopf fuers Angebot - gewaehlt wird im Popup")
kunde("Anna", "Vorab") kunde("Anna", "Vorab")
nummer_vorher = f.r_nummer.get() nummer_vorher = f.r_nummer.get()
@ -145,33 +147,41 @@ klicke_im_popup("Abbrechen")
f.pdf_erstellen() f.pdf_erstellen()
pruefe(not os.listdir(ordner), f"Abbrechen schreibt nichts ({os.listdir(ordner)})") pruefe(not os.listdir(ordner), f"Abbrechen schreibt nichts ({os.listdir(ordner)})")
# ---- 3. Proforma waehlen ------------------------------------------------- # ---- 3. Angebot waehlen --------------------------------------------------
klicke_im_popup("Proforma") klicke_im_popup("Angebot")
f.pdf_erstellen() f.pdf_erstellen()
dateien = sorted(os.listdir(ordner)) pruefe(sorted(os.listdir(ordner)) == ["Angebote"],
pruefe(dateien == ["Proforma_2026-P001_Vorab.pdf"], f"die Proforma liegt da ({dateien})") f"im Rechnungsordner liegt nur der Unterordner ({sorted(os.listdir(ordner))})")
angebote = os.path.join(ordner, "Angebote")
dateien = sorted(os.listdir(angebote))
pruefe(dateien == ["Angebot_2026-A001_Vorab.pdf"],
f"das Angebot liegt im Unterordner Angebote ({dateien})")
# ---- 4. DIE Hauptsache: die Rechnungsnummer ist unberuehrt -------------- # ---- 4. DIE Hauptsache: die Rechnungsnummer ist unberuehrt --------------
pruefe(f.r_nummer.get() == nummer_vorher, pruefe(f.r_nummer.get() == nummer_vorher,
f"das Nummernfeld steht unveraendert auf {nummer_vorher} ({f.r_nummer.get()})") f"das Nummernfeld steht unveraendert auf {nummer_vorher} ({f.r_nummer.get()})")
pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher, pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher,
f"der Rechnungszaehler ist unveraendert ({f.cfg.get('rechnung_zaehler')})") f"der Rechnungszaehler ist unveraendert ({f.cfg.get('rechnung_zaehler')})")
pruefe(f.cfg.get("proforma_zaehler") == 1, pruefe(f.cfg.get("angebot_zaehler") == 1,
f"nur der Proforma-Zaehler ist weitergelaufen ({f.cfg.get('proforma_zaehler')})") f"nur der Angebots-Zaehler ist weitergelaufen ({f.cfg.get('angebot_zaehler')})")
# ---- 5. Was auf dem Blatt steht ----------------------------------------- # ---- 5. Was auf dem Blatt steht -----------------------------------------
from pypdf import PdfReader # noqa: E402 from pypdf import PdfReader # noqa: E402
pfad = os.path.join(ordner, "Proforma_2026-P001_Vorab.pdf") pfad = os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf")
seiten = PdfReader(pfad).pages seiten = PdfReader(pfad).pages
pruefe(len(seiten) == 1, f"eine Seite - kein Blatt fuers Amt ({len(seiten)})") pruefe(len(seiten) == 1, f"eine Seite - kein Blatt fuers Amt ({len(seiten)})")
text = (seiten[0].extract_text() or "").replace("\n", " ") text = (seiten[0].extract_text() or "").replace("\n", " ")
pruefe("Proforma-Rechnung" in text, "es steht deutlich Proforma-Rechnung drauf") pruefe("Unverbindliches Angebot" in text, "es steht deutlich Unverbindliches Angebot drauf")
pruefe("Rechnung" not in text.split("Unverbindliches Angebot")[0],
"und das Wort Rechnung steht nicht in der Ueberschrift")
pruefe("keine Rechnung im Sinne des § 14 UStG" in text, pruefe("keine Rechnung im Sinne des § 14 UStG" in text,
"mit dem Satz, dass es keine Rechnung nach § 14 UStG ist") "mit dem Satz, dass es keine Rechnung nach § 14 UStG ist")
pruefe("keine Rechnungsnummer" in text, "und dass die Nummer keine Rechnungsnummer ist") pruefe("Angebotsnummer" in text, "die Nummer heisst Angebotsnummer")
pruefe("2026-P001" in text and "2026-001" not in text, pruefe("Rechnungsnummer" not in text,
"nur die Proforma-Nummer steht drauf, keine Rechnungsnummer") "das Wort Rechnungsnummer kommt gar nicht mehr vor")
pruefe("2026-A001" in text and "2026-001" not in text,
"nur die Angebots-Nummer steht drauf, keine Rechnungsnummer")
pruefe("Mehrwertsteuer" not in text and "Nettobetrag" not in text, pruefe("Mehrwertsteuer" not in text and "Nettobetrag" not in text,
"kein Steuerbetrag und kein Nettoausweis (§ 14c UStG)") "kein Steuerbetrag und kein Nettoausweis (§ 14c UStG)")
pruefe("Beherbergungssteuer" not in text and "Zwischensumme" not in text, pruefe("Beherbergungssteuer" not in text and "Zwischensumme" not in text,
@ -182,7 +192,7 @@ pruefe("Voraussichtlicher Gesamtbetrag" in text,
"der Betrag heisst voraussichtlich, nicht Rechnungsbetrag") "der Betrag heisst voraussichtlich, nicht Rechnungsbetrag")
meta = BE.kenndaten_lesen(pfad) or {} meta = BE.kenndaten_lesen(pfad) or {}
pruefe(meta.get("art") == "proforma", f"art = proforma ({meta.get('art')})") pruefe(meta.get("art") == "angebot", f"art = angebot ({meta.get('art')})")
pruefe(float(meta.get("zwischensumme") or 0) == 0.0 and float(meta.get("gesamt") or 0) == 0.0, pruefe(float(meta.get("zwischensumme") or 0) == 0.0 and float(meta.get("gesamt") or 0) == 0.0,
f"die Kenndaten tragen Nullen ({meta.get('zwischensumme')}, {meta.get('gesamt')})") f"die Kenndaten tragen Nullen ({meta.get('zwischensumme')}, {meta.get('gesamt')})")
@ -191,20 +201,20 @@ antworten["Storno"] = True
klicke_im_popup("Rechnung 2026-001 erstellen") klicke_im_popup("Rechnung 2026-001 erstellen")
f.pdf_erstellen() f.pdf_erstellen()
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")), pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")),
f"die Rechnung 2026-001 ist geschrieben ({sorted(os.listdir(ordner))})") f"die Rechnung liegt bei den Rechnungen ({sorted(os.listdir(ordner))})")
pruefe(f.cfg.get("rechnung_zaehler") == 1, pruefe(f.cfg.get("rechnung_zaehler") == 1,
f"jetzt erst laeuft der Rechnungszaehler weiter ({f.cfg.get('rechnung_zaehler')})") f"jetzt erst laeuft der Rechnungszaehler weiter ({f.cfg.get('rechnung_zaehler')})")
# ---- 7. Die zweite Proforma zaehlt in ihrer Reihe weiter --------------- # ---- 7. Das zweite Angebot zaehlt in seiner Reihe weiter ---------------
f.reset_formular() f.reset_formular()
kunde("Bernd", "Zweitvorab") kunde("Bernd", "Zweitvorab")
klicke_im_popup("Proforma") klicke_im_popup("Angebot")
f.pdf_erstellen() f.pdf_erstellen()
pruefe(os.path.exists(os.path.join(ordner, "Proforma_2026-P002_Zweitvorab.pdf")), pruefe(os.path.exists(os.path.join(angebote, "Angebot_2026-A002_Zweitvorab.pdf")),
f"die zweite Proforma heisst P002 ({sorted(os.listdir(ordner))})") f"das zweite Angebot heisst A002 ({sorted(os.listdir(angebote))})")
f.destroy() f.destroy()
# ---- 8. Das Steuerjournal bucht die Proforma NICHT -------------------- # ---- 8. Das Steuerjournal bucht das Angebot NICHT ---------------------
sys.path.append(JOURNAL) sys.path.append(JOURNAL)
import db as BST # noqa: E402 import db as BST # noqa: E402
@ -214,8 +224,8 @@ buchungen = journal.buchungen(jahr=2026)
pruefe(len(buchungen) == 1, f"nur die echte Rechnung ist gebucht ({len(buchungen)})") pruefe(len(buchungen) == 1, f"nur die echte Rechnung ist gebucht ({len(buchungen)})")
pruefe(buchungen and buchungen[0].voll_nummer == "2026-001", pruefe(buchungen and buchungen[0].voll_nummer == "2026-001",
f"und zwar 2026-001 ({[b.voll_nummer for b in buchungen]})") f"und zwar 2026-001 ({[b.voll_nummer for b in buchungen]})")
pruefe(not any("P0" in b.voll_nummer for b in buchungen), pruefe(not any("A0" in b.voll_nummer for b in buchungen),
"keine Proforma-Nummer im Journal") "keine Angebots-Nummer im Journal")
journal.schliessen() journal.schliessen()
if not fehler: if not fehler: