Storno statt Ueberschreiben: Rechnungskette 005 -> Storno -> Korrektur

Eine ausgestellte Rechnung darf nicht geaendert und nicht geloescht werden
(§ 146 Abs. 4 AO), und jede Nummer gibt es nur einmal (§ 14 Abs. 4 Nr. 4
UStG). Korrigiert wird deshalb ueber ein eigenes Dokument mit neuer Nummer,
das sich auf die alte bezieht (§ 31 Abs. 5 UStDV).

- Knopf "Rechnung stornieren": alte Nummer waehlen, Programm liest deren PDF
  und schreibt eine Stornorechnung mit eigener neuer Nummer, Betraege
  negativ, mit Bezug auf Nummer und Datum der alten Rechnung.
- Danach steht das Formular auf der naechsten Nummer, gefuellt aus der alten
  Rechnung; die Nummer ist gesperrt. "PDF erstellen" fragt noch einmal nach
  und legt bei Nein das Storno-PDF wieder weg - dann bleibt allein das
  Original stehen.
- Die alte Rechnung wird nie angefasst.
- Metadaten v3: art / storno_zu / ersetzt / storno_nummer, dazu Anschrift und
  Positionen, damit eine Rechnung wieder ins Formular geladen werden kann.
  Alle Felder aus v1/v2 bleiben unveraendert - daran haengt das Steuerjournal.
- Zaehler laeuft nur noch vorwaerts; eine vergebene Nummer wird nicht mehr
  ueberschrieben, sondern die naechste freie angeboten.
- Combobox im Storno-Dialog blieb leer: die StringVar hing nur an einer
  lokalen Variable und wurde weggeraeumt. Jetzt ohne textvariable.

Prueflauf (pruef_storno.py, 36 Pruefungen gruen): echte PDFs in einem
Wegwerf-Ordner, danach liest der Steuerrechner den Ordner - 001 + Storno +
Korrektur ergeben genau die berichtigte Rechnung, kein Beleg unsicher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKvGkdNW1vKdnMPAM9Bp3X
This commit is contained in:
TheMockTv 2026-09-02 19:06:45 +02:00
parent 1564d769c9
commit 790ac4c0f6
4 changed files with 739 additions and 16 deletions

451
app.py
View file

@ -10,6 +10,7 @@ Bedienung:
Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen".
"""
import json
import os
import sys
import datetime
@ -48,6 +49,140 @@ def safe_filename(s):
return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung"
def vorhandene_rechnungen(ordner, nummer):
"""Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren.
Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"),
darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer
unter einem anderen Namen als zweite Datei durch.
"""
kopf = "Rechnung_" + safe_filename(nummer)
try:
namen = os.listdir(ordner)
except OSError:
return []
treffer = []
for name in namen:
if not name.lower().endswith(".pdf"):
continue
stamm = name[:-4]
# exakt die Nummer oder Nummer + "_Kundenname" - "2026-130" faellt raus
if stamm == kopf or stamm.startswith(kopf + "_"):
treffer.append(os.path.join(ordner, name))
return sorted(treffer)
def kenndaten_lesen(pfad):
"""Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine.
Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das
spiegeln, was rausgegangen ist.
"""
try:
from pypdf import PdfReader
roh = (PdfReader(pfad).metadata or {}).get("/Subject") or ""
if not str(roh).strip().startswith("{"):
return None
daten = json.loads(str(roh))
except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF
print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})")
return None
if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"):
return None
daten["_pfad"] = pfad
return daten
def rechnungen_im_ordner(ordner):
"""Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst.
Stornos und schon stornierte Rechnungen sind mit drin - welche davon
stornierbar ist, entscheidet der Aufrufer.
"""
try:
namen = sorted(os.listdir(ordner))
except OSError:
return []
gefunden = []
for name in namen:
if not name.lower().endswith(".pdf") or not name.startswith("Rechnung_"):
continue
daten = kenndaten_lesen(os.path.join(ordner, name))
if daten:
gefunden.append(daten)
gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True)
return gefunden
def storno_rechnung_bauen(meta, cfg):
"""Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno.
Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der
alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes,
und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten.
"""
r = berechnung.Rechnung()
nummer = meta.get("rechnungsnummer", "")
bloecke = meta.get("ust_bloecke") or []
if not bloecke:
# v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt
# die USt unter den Tisch.
brutto = float(meta.get("zwischensumme") or 0)
satz = int((cfg.get("steuersaetze") or [{"satz": 7}])[0].get("satz", 7))
netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto
bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto),
"brutto": brutto}]
saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])}
for i, b in enumerate(bloecke, start=1):
satz = int(float(b.get("satz", 0) or 0))
brutto = -abs(berechnung.runde(b.get("brutto") or 0))
netto = -abs(berechnung.runde(b.get("netto") or 0))
ust = -abs(berechnung.runde(b.get("ust") or 0))
r.zeilen.append(berechnung.PositionsZeile(
nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)",
satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False,
brutto=brutto, netto=netto))
info = saetze.get(satz, {})
r.bloecke.append(berechnung.Steuerblock(
satz=satz, netto=netto, ust=ust,
bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"),
extra_blatt=bool(info.get("extra_blatt", False))))
r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke))
r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke))
r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt)
# Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht.
# Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem
# berechne() den Aufschlag baut.
steuer = berechnung.runde(meta.get("steuer_betrag") or 0)
if steuer:
extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {})
r.aufschlaege.append(berechnung.Aufschlag(
satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0),
bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"),
basis=r.brutto, betrag=-abs(steuer), extra_blatt=True))
r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege))
return r
def naechste_freie_nummer(ordner, cfg):
"""Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt.
Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner
hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind.
"""
jahr = cfg.get("rechnung_jahr", datetime.date.today().year)
z = cfg.get("rechnung_zaehler", 0)
for _ in range(1000):
z += 1
kandidat = config.format_rechnungsnummer(jahr, z)
if not vorhandene_rechnungen(ordner, kandidat):
return kandidat
return None
def einzelinstanz_pruefen():
"""Sperrt das Programm gegen ein zweites Fenster.
@ -162,6 +297,9 @@ class RechnungsApp(tk.Tk):
self.anzahl_vars = {} # nr -> StringVar
self.personen_vars = {} # nr -> StringVar
self.gesamt_lbls = {} # nr -> Label
# Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss.
# Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette.
self.storno_stand = None
self._apply_theme() # Farben nach Windows hell/dunkel
self._build_menu()
@ -223,10 +361,20 @@ class RechnungsApp(tk.Tk):
style.configure("TCheckbutton", background=bg, foreground=fg)
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg)
# Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG,
# sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert
# unsichtbar im Feld.
style.configure("TCombobox", fieldbackground=field, foreground=fg,
background=field, arrowcolor=fg)
background=field, arrowcolor=fg,
selectbackground=field, selectforeground=fg)
style.map("TCombobox", fieldbackground=[("readonly", field)],
foreground=[("readonly", fg)], background=[("readonly", field)])
foreground=[("readonly", fg)], background=[("readonly", field)],
selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)])
# Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget
self.option_add("*TCombobox*Listbox.background", field)
self.option_add("*TCombobox*Listbox.foreground", fg)
self.option_add("*TCombobox*Listbox.selectBackground", sel)
self.option_add("*TCombobox*Listbox.selectForeground", fg)
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
bordercolor=bg, lightcolor=bg, darkcolor=bg,
@ -390,11 +538,20 @@ class RechnungsApp(tk.Tk):
ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
ent = ttk.Entry(info, textvariable=var, width=18)
ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
if var is self.r_nummer:
# Waehrend eines Stornos wird das Feld gesperrt - die Nummern
# vergibt dann das Programm, damit keine doppelt sein kann.
self.nummer_entry = ent
if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende
ent.bind("<KeyRelease>", self._auto_datum_evt)
ttk.Label(info, text="Nächte").grid(row=4, column=0, sticky="w", pady=2)
ttk.Label(info, textvariable=self.r_naechte, font=("Segoe UI", 10, "bold")).grid(
row=4, column=1, sticky="w", padx=(8, 0))
# Storno-Hinweis: steht nur da, solange ein Storno im Zwischenspeicher liegt.
self.storno_hinweis = tk.StringVar(value="")
ttk.Label(info, textvariable=self.storno_hinweis, foreground="#b00020",
font=("Segoe UI", 9, "bold"), wraplength=200, justify="left").grid(
row=5, column=0, columnspan=2, sticky="w", pady=(6, 0))
info.columnconfigure(1, weight=1)
self.r_anreise.trace_add("write", lambda *a: self._update_naechte())
self.r_abreise.trace_add("write", lambda *a: self._update_naechte())
@ -527,6 +684,7 @@ class RechnungsApp(tk.Tk):
links = ttk.Frame(f)
links.pack(side="left")
ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4)
ttk.Button(links, text="Rechnung stornieren", command=self.dlg_storno, width=20).pack(side="left", padx=4)
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4)
# --------------------------------------------------------- Berechnung
@ -578,6 +736,219 @@ class RechnungsApp(tk.Tk):
jahr, zaehler = heute_jahr, 0
self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1))
# ------------------------------------------------------------- Storno
def _storno_anzeigen(self):
"""Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen."""
stand = self.storno_stand
if stand:
self.storno_hinweis.set(
f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n"
f"Beim Erstellen wird noch einmal gefragt.")
self.nummer_entry.configure(state="readonly")
else:
self.storno_hinweis.set("")
self.nummer_entry.configure(state="normal")
def _storno_verwerfen(self, grund=""):
"""Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren.
Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das
Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst.
"""
stand = self.storno_stand
if not stand:
return
try:
if os.path.exists(stand["pfad"]):
os.remove(stand["pfad"])
print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}")
except OSError as e: # noqa: BLE001
messagebox.showwarning("Storno-PDF blieb liegen",
f"Die Storno-PDF konnte nicht gelöscht werden:\n{e}")
self.cfg["rechnung_jahr"] = stand["jahr_vorher"]
self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"]
config.save_config(self.cfg)
self.storno_stand = None
self._storno_anzeigen()
self._neue_nummer_vorschlagen()
if grund:
print(f"[storno] {grund}")
def dlg_storno(self):
"""Storno-Dialog: alte Nummer wählen -> Storno-PDF schreiben -> Formular füllen."""
if self.storno_stand:
messagebox.showinfo(
"Storno liegt schon bereit",
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
f"{self.storno_stand['zu']} ist noch offen.\n\n"
"Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.")
return
out_dir = config.ensure_output_dir(self.cfg)
alle = rechnungen_im_ordner(out_dir)
stornierte = {str(d.get("storno_zu") or "") for d in alle if d.get("art") == "storno"}
offen = [d for d in alle
if d.get("art") != "storno" and d.get("rechnungsnummer") not in stornierte]
if not offen:
messagebox.showwarning(
"Nichts zu stornieren",
f"Im Ordner\n{out_dir}\nliegt keine stornierbare Rechnung.")
return
d = tk.Toplevel(self)
d.title("Rechnung stornieren")
d.transient(self)
d.grab_set()
self._style_dialog(d)
frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True)
ttk.Label(frm, justify="left", text=
"Die alte Rechnung bleibt unverändert liegen – sie darf weder geändert\n"
"noch gelöscht werden. Das Programm schreibt eine Stornorechnung mit\n"
"eigener neuer Nummer, die sich auf sie bezieht.").grid(
row=0, column=0, columnspan=2, sticky="w", pady=(0, 10))
ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3)
# Bewusst OHNE textvariable: eine StringVar, die nur an einer lokalen
# Variable haengt, raeumt Python nach dem Dialogaufbau weg - Tk loescht
# dann die Tcl-Variable und das Feld steht leer da.
werte = [f"{x['rechnungsnummer']} {x.get('datum','')} {x.get('nachname','')}".strip()
for x in offen]
box = ttk.Combobox(frm, values=werte, width=42, state="readonly")
box.grid(row=1, column=1, sticky="ew", pady=3, padx=(8, 0))
box.current(0)
v_info = tk.StringVar(value="")
ttk.Label(frm, textvariable=v_info, justify="left", foreground=self.col_muted).grid(
row=2, column=0, columnspan=2, sticky="w", pady=(8, 0))
def gewaehlt():
i = box.current()
return offen[i] if 0 <= i < len(offen) else None
def vorschau(*_a):
m = gewaehlt()
if not m:
return
v_info.set(f"Rechnungsbetrag {berechnung.eur(m.get('zwischensumme') or 0)}, "
f"Gesamt {berechnung.eur(m.get('gesamt') or 0)}\n"
f"Der Storno lautet über {berechnung.eur(-abs(float(m.get('gesamt') or 0)))}.")
box.bind("<<ComboboxSelected>>", vorschau)
vorschau()
def anlegen():
meta = gewaehlt()
if not meta:
return
d.destroy()
self._storno_anlegen(meta, out_dir)
btns = ttk.Frame(frm)
btns.grid(row=3, column=0, columnspan=2, pady=(14, 0), sticky="e")
ttk.Button(btns, text="Storno erstellen", command=anlegen).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
frm.columnconfigure(1, weight=1)
def _storno_anlegen(self, meta, out_dir):
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
alt_nummer = str(meta.get("rechnungsnummer", ""))
nummer = naechste_freie_nummer(out_dir, self.cfg)
if not nummer:
messagebox.showerror("Keine freie Nummer",
"Es wurde keine freie Rechnungsnummer gefunden.")
return
r = storno_rechnung_bauen(meta, self.cfg)
heute = datetime.date.today().strftime("%d.%m.%Y")
namenszeile = " ".join(t for t in (str(meta.get("vorname") or ""),
str(meta.get("nachname") or "")) if t)
kunde = meta.get("kunde") or {}
kunde_zeilen = ([kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""),
kunde.get("strasse", ""), kunde.get("plzort", "")]
if kunde else ["", namenszeile, "", "", ""])
kopf = {
"rechnungsnummer": nummer,
"datum": heute,
"anreise": "", "abreise": "", "naechte": "",
"kunde_zeilen": kunde_zeilen,
"nachname": str(meta.get("nachname") or ""),
"vorname": str(meta.get("vorname") or ""),
"art": "storno",
"storno_zu": alt_nummer,
"storno_datum": str(meta.get("datum") or ""),
"kunde": kunde,
}
name = f"Storno_{safe_filename(nummer)}"
if kopf["nachname"]:
name += "_" + safe_filename(kopf["nachname"])
pfad = os.path.join(out_dir, name + ".pdf")
try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
messagebox.showerror("Fehler beim Erstellen",
f"Storno-PDF konnte nicht erstellt werden:\n{e}")
print("[storno] FEHLER:", e)
return
self.storno_stand = {
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
"zu_datum": str(meta.get("datum") or ""),
"jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year),
"zaehler_vorher": self.cfg.get("rechnung_zaehler", 0),
}
self._zaehler_aus_nummer_speichern(nummer)
print(f"[storno] erstellt -> {pfad}")
fehlend = self._formular_aus_meta(meta)
self.r_nummer.set(naechste_freie_nummer(out_dir, self.cfg) or "")
self._storno_anzeigen()
hinweis = ""
if fehlend:
hinweis = ("\n\nAus der alten PDF ließen sich nicht alle Felder übernehmen "
f"({fehlend}) – die stammt noch aus einer älteren Fassung. "
"Bitte vor dem Erstellen prüfen.")
messagebox.showinfo(
"Storno erstellt",
f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n"
f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und "
f"\"PDF erstellen\" drücken.{hinweis}")
def _formular_aus_meta(self, meta):
"""Formular mit den Daten der alten Rechnung füllen.
Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2)
tragen weder Anschrift noch Positionen in den Kenndaten.
"""
self.k_vorname.set(str(meta.get("vorname") or ""))
self.k_name.set(str(meta.get("nachname") or ""))
kunde = meta.get("kunde") or {}
self.k_anrede.set(str(kunde.get("anrede", "") or ""))
self.k_zusatz.set(str(kunde.get("zusatz", "") or ""))
self.k_strasse.set(str(kunde.get("strasse", "") or ""))
self.k_plzort.set(str(kunde.get("plzort", "") or ""))
self.r_anreise.set(str(meta.get("anreise", "") or ""))
self.r_abreise.set(str(meta.get("abreise", "") or ""))
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
for nr, var in self.anzahl_vars.items():
var.set("")
self.personen_vars[nr].set("")
for p in meta.get("positionen") or []:
nr = p.get("nr")
if nr in self.anzahl_vars:
self.anzahl_vars[nr].set(str(p.get("anzahl", "") or ""))
self.personen_vars[nr].set(str(p.get("personen", "") or ""))
self.recalc()
fehlt = []
if not kunde:
fehlt.append("Anschrift")
if not (meta.get("positionen") or []):
fehlt.append("Leistungen")
return ", ".join(fehlt)
def pdf_erstellen(self):
r = self.recalc()
if not r.zeilen:
@ -601,6 +972,26 @@ class RechnungsApp(tk.Tk):
namenszeile = " ".join(t for t in (vorname, nachname) if t)
kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
self.k_strasse.get(), self.k_plzort.get()]
# Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung.
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
# geloescht und es bleibt allein die urspruengliche Rechnung stehen.
stand = self.storno_stand
if stand:
if not messagebox.askyesno(
"Storno wirklich durchführen?",
f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, "
f"und {nummer} wird die berichtigte Rechnung.\n\n"
"Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie "
"ihm zugegangen ist.\n\n"
"Ja = Storno behalten und berichtigte Rechnung erstellen\n"
f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}",
icon="warning", default="yes"):
self._storno_verwerfen("vom Benutzer verworfen")
messagebox.showinfo(
"Storno verworfen",
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
return
kopf = {
"rechnungsnummer": nummer,
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
@ -610,8 +1001,46 @@ class RechnungsApp(tk.Tk):
"kunde_zeilen": kunde_zeilen,
"nachname": nachname,
"vorname": vorname,
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
"strasse": self.k_strasse.get().strip(),
"plzort": self.k_plzort.get().strip()},
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
for z in r.zeilen],
}
if stand:
kopf["art"] = "korrektur"
kopf["ersetzt"] = stand["zu"]
kopf["storno_nummer"] = stand["nummer"]
out_dir = config.ensure_output_dir(self.cfg)
# Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte
# Rechnung muss unveraendert bestehen bleiben (§ 146 Abs. 4 AO), und jede
# Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer.
vorhanden = vorhandene_rechnungen(out_dir, nummer)
if vorhanden:
liste = "\n".join("- " + os.path.basename(x) for x in vorhanden)
frei = naechste_freie_nummer(out_dir, self.cfg)
if not frei:
messagebox.showerror(
"Rechnungsnummer schon vergeben",
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
"Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.")
return
if not messagebox.askyesno(
"Rechnungsnummer schon vergeben",
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
"Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben "
"noch gelöscht werden (§ 14 Abs. 4 Nr. 4 UStG, § 146 Abs. 4 AO).\n\n"
f"Stattdessen die nächste freie Nummer {frei} verwenden?",
icon="warning", default="yes"):
return
nummer = frei
self.r_nummer.set(nummer)
kopf["rechnungsnummer"] = nummer
fname = f"Rechnung_{safe_filename(nummer)}"
if self.k_name.get().strip():
fname += "_" + safe_filename(self.k_name.get().strip())
@ -630,8 +1059,19 @@ class RechnungsApp(tk.Tk):
print(f"[pdf] erstellt -> {pfad}")
hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else ""
storno_pfad = None
if stand:
# Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen.
storno_pfad = stand["pfad"]
self.storno_stand = None
self._storno_anzeigen()
hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} "
f"({os.path.basename(storno_pfad)}).\n"
"Beide Blätter gehören zum Gast.") + hinweis
if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
self._oeffne_datei(pfad)
if storno_pfad:
self._oeffne_datei(storno_pfad)
def _zaehler_aus_nummer_speichern(self, nummer):
try:
@ -639,6 +1079,13 @@ class RechnungsApp(tk.Tk):
jahr, z = int(jahr_s), int(z_s)
except (ValueError, AttributeError):
return
# Der Zaehler darf nur vorwaerts. Wird eine alte Rechnung ueberschrieben,
# wuerde er sonst zurueckfallen und die naechsten Nummern noch einmal
# vergeben - genau der Fehler, den wir loswerden wollen.
alt_jahr = self.cfg.get("rechnung_jahr", 0)
alt_z = self.cfg.get("rechnung_zaehler", 0)
if jahr < alt_jahr or (jahr == alt_jahr and z <= alt_z):
return
self.cfg["rechnung_jahr"] = jahr
self.cfg["rechnung_zaehler"] = z
config.save_config(self.cfg)

View file

@ -208,10 +208,21 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
if len(kunde_flow) == 1:
kunde_flow.append(Paragraph("&nbsp;", st["addr"]))
art = kopf.get("art", "rechnung")
info_rows = [
("Rechnungsnummer:", kopf.get("rechnungsnummer", "")),
("Stornonummer:" if art == "storno" else "Rechnungsnummer:",
kopf.get("rechnungsnummer", "")),
("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"]))
elif art == "korrektur" and kopf.get("ersetzt"):
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
if kopf.get("anreise"):
info_rows.append(("Anreise:", kopf.get("anreise", "")))
if kopf.get("abreise"):
@ -239,7 +250,10 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
# Ueberschrift (auf dem Amt-Blatt mit Zusatz)
# Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht
# in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist.
titel = "Rechnungsübersicht" + (" (für das Amt)" if gesamtrechnung_betont else "")
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
# eindeutig als Aufhebung erkennbar ist.
titel = "Stornorechnung" if art == "storno" else "Rechnungsübersicht"
titel += " (für das Amt)" if gesamtrechnung_betont else ""
story.append(Paragraph(titel, st["h"]))
# Positionstabelle
@ -342,16 +356,38 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
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))
# ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
# ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
if not gesamtrechnung_betont:
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']}"
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.", 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"):
@ -374,11 +410,21 @@ def _kenndaten(rechnung, kopf):
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
daten = {
"quelle": "rechnungstool",
"version": 2,
"version": 3,
"rechnungsnummer": kopf.get("rechnungsnummer", ""),
"datum": kopf.get("datum", ""),
"nachname": kopf.get("nachname", ""),
@ -396,6 +442,16 @@ def _kenndaten(rechnung, kopf):
"brutto": runde(b.brutto)}
for b in rechnung.bloecke
],
# neu in Version 3
"art": kopf.get("art", "rechnung"),
"storno_zu": kopf.get("storno_zu", ""),
"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", []),
}
return json.dumps(daten, ensure_ascii=False)
@ -419,7 +475,8 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
ziel, pagesize=A4,
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
title=f"Rechnung {kopf.get('rechnungsnummer','')}",
title=("Stornorechnung" if kopf.get("art") == "storno" else "Rechnung")
+ f" {kopf.get('rechnungsnummer','')}",
author=cfg["firma"].get("name", ""),
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
)

50
pruef_nummern.py Normal file
View file

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""Pruefstand: findet vorhandene_rechnungen() eine schon vergebene Nummer?
Kein Rateraum: legt echte Dateien in einem Wegwerf-Ordner an und fragt die
Funktion aus app.py direkt.
"""
import os
import sys
import tempfile
sys.path.insert(0, r"C:\claude\rechnungstool")
from app import vorhandene_rechnungen # noqa: E402
ordner = tempfile.mkdtemp(prefix="rechnungspruef_")
dateien = [
"Rechnung_2026-013_Meier.pdf", # dieselbe Nummer, Kunde Meier
"Rechnung_2026-130_Schmidt.pdf", # laengere Nummer - darf NICHT treffen
"Rechnung_2026-014_Meier.pdf", # andere Nummer
"Rechnung_2026-020.pdf", # ohne Kundenname
"Notiz_2026-013.txt", # kein PDF
]
for d in dateien:
with open(os.path.join(ordner, d), "w") as f:
f.write("x")
faelle = [
("2026-013", ["Rechnung_2026-013_Meier.pdf"], "Nummer vergeben, anderer Kundenname"),
("2026-130", ["Rechnung_2026-130_Schmidt.pdf"], "laengere Nummer trifft sich selbst"),
("2026-020", ["Rechnung_2026-020.pdf"], "ohne Kundenname im Dateinamen"),
("2026-099", [], "freie Nummer"),
("2026-01", [], "Teilnummer darf nicht auf 2026-013 anspringen"),
]
fehler = 0
for nummer, erwartet, was in faelle:
ist = [os.path.basename(p) for p in vorhandene_rechnungen(ordner, nummer)]
ok = ist == erwartet
fehler += 0 if ok else 1
print((" OK " if ok else "FEHLER") + f" | {nummer} -> {ist} ({was})")
# leerer / nicht vorhandener Ordner darf nicht knallen
if vorhandene_rechnungen(os.path.join(ordner, "gibtsnicht"), "2026-013") != []:
print("FEHLER | fehlender Ordner")
fehler += 1
else:
print(" OK | fehlender Ordner gibt leere Liste")
print("---")
print("alles gruen" if fehler == 0 else f"{fehler} Fehler")
sys.exit(1 if fehler else 0)

169
pruef_storno.py Normal file
View file

@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""Pruefstand fuer die Storno-Kette - kompletter Durchlauf in einem Wegwerf-Ordner.
Geprueft wird an ECHTEN PDFs, die das Programm selbst schreibt:
1. Rechnung 2026-001 erstellen
2. sie stornieren -> Storno 2026-002 mit negativen Betraegen
3. berichtigte Rechnung 2026-003 mit geaenderter Menge
4. der Steuerrechner liest den Ordner: 001 + 002 + 003 muss 003 ergeben
5. Rueckweg: Storno anlegen und verwerfen -> PDF weg, Zaehler zurueck, Original da
Die echte config.json wird NICHT angefasst - es wird mit einer Kopie im
Wegwerf-Ordner gearbeitet.
"""
import json
import os
import shutil
import sys
import tempfile
QUELLE = r"C:\claude\rechnungstool"
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="storno_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
import app as A # noqa: E402
from tkinter import messagebox # noqa: E402
fehler = []
def pruefe(bedingung, text):
print((" OK | " if bedingung else "FEHLER | ") + text)
if not bedingung:
fehler.append(text)
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
antworten = {}
gefragt = []
def askyesno(titel, text, **kw):
gefragt.append(titel)
for schlue, wert in antworten.items():
if schlue in titel:
return wert
return False
messagebox.askyesno = askyesno
messagebox.showinfo = lambda *a, **k: None
messagebox.showwarning = lambda *a, **k: print("[warnung]", a[0] if a else "")
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "", a[1] if len(a) > 1 else "")
f = A.RechnungsApp()
f.update()
f.cfg["output_dir"] = ordner
# ---- 1. normale Rechnung ------------------------------------------------
f.k_vorname.set("Anna")
f.k_name.set("Beispiel")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.r_anreise.set("01.08.2026")
f.r_abreise.set("05.08.2026")
erste = sorted(f.anzahl_vars)[0]
f.anzahl_vars[erste].set("4")
f.update()
f.pdf_erstellen()
erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf")
pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben")
meta1 = A.kenndaten_lesen(erste_pdf)
pruefe(bool(meta1), "Kenndaten der Rechnung lesbar")
pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})")
pruefe(meta1.get("art") == "rechnung", "art = rechnung")
pruefe(len(meta1.get("positionen") or []) == 1, "Positionen stehen in den Kenndaten")
pruefe(bool(meta1.get("kunde", {}).get("plzort")), "Anschrift steht in den Kenndaten")
brutto1 = float(meta1["zwischensumme"])
gesamt1 = float(meta1["gesamt"])
print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}")
# ---- 2. stornieren ------------------------------------------------------
offen = [d for d in A.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden")
f._storno_anlegen(offen[0], ordner)
pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher")
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})")
meta2 = A.kenndaten_lesen(storno_pdf)
pruefe(meta2.get("art") == "storno", "art = storno")
pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001")
pruefe(meta2.get("storno_datum") == meta1.get("datum"), "Datum der alten Rechnung steht drin")
pruefe(float(meta2["zwischensumme"]) == -brutto1, f"Zwischensumme negativ ({meta2['zwischensumme']})")
pruefe(float(meta2["gesamt"]) == -gesamt1, f"Gesamt negativ ({meta2['gesamt']})")
pruefe(all(b["netto"] <= 0 and b["ust"] <= 0 for b in meta2["ust_bloecke"]),
"alle USt-Bloecke negativ")
pruefe(f.r_nummer.get() == "2026-003", f"Formular steht auf 2026-003 (ist {f.r_nummer.get()})")
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
pruefe(f.k_plzort.get() == "12345 Musterstadt", "Anschrift ins Formular uebernommen")
pruefe(f.anzahl_vars[erste].get() == "4.0", f"Menge uebernommen (ist {f.anzahl_vars[erste].get()})")
# ---- 3. berichtigte Rechnung -------------------------------------------
antworten["Storno wirklich"] = True
f.anzahl_vars[erste].set("3") # das war der Fehler: 4 statt 3 Naechte
f.update()
f.pdf_erstellen()
dritte = os.path.join(ordner, "Rechnung_2026-003_Beispiel.pdf")
pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben")
pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer")
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
meta3 = A.kenndaten_lesen(dritte)
pruefe(meta3.get("art") == "korrektur", "art = korrektur")
pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001")
pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002")
pruefe(os.path.exists(erste_pdf), "die urspruengliche Rechnung liegt unangetastet da")
gesamt3 = float(meta3["gesamt"])
print(f" Rechnung 003: gesamt {gesamt3:.2f}")
# ---- 4. was sieht der Steuerrechner? ------------------------------------
sys.path.insert(0, r"C:\claude\steuerrechner")
import einnahmen # noqa: E402
liste, kaputt = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
pruefe(len(liste) == 3, f"Steuerrechner liest 3 Belege (liest {len(liste)})")
pruefe(not kaputt, f"kein Beleg uebersprungen ({kaputt})")
pruefe(not any(e.unsicher for e in liste),
"kein Beleg als unsicher markiert")
summe = round(sum(e.gesamt for e in liste), 2)
netto = round(sum(e.netto for e in liste), 2)
ust = round(sum(e.ust for e in liste), 2)
pruefe(abs(summe - gesamt3) < 0.005,
f"Jahressumme = nur die berichtigte Rechnung ({summe:.2f} vs {gesamt3:.2f})")
print(f" Summe {summe:.2f} | Netto {netto:.2f} | USt {ust:.2f}")
# ---- 5. Rueckweg: Storno anlegen und verwerfen --------------------------
antworten["Storno wirklich"] = False
offen = [d for d in A.rechnungen_im_ordner(ordner)
if d.get("art") == "korrektur"]
f._storno_anlegen(offen[0], ordner)
storno2 = f.storno_stand["pfad"]
zaehler_mit_storno = f.cfg["rechnung_zaehler"]
pruefe(os.path.exists(storno2), "zweites Storno geschrieben")
f.anzahl_vars[erste].set("2")
f.update()
f.pdf_erstellen() # -> Frage wird mit Nein beantwortet
pruefe(not os.path.exists(storno2), "verworfenes Storno-PDF ist geloescht")
pruefe(f.storno_stand is None, "Zwischenspeicher geleert")
pruefe(f.cfg["rechnung_zaehler"] == zaehler_mit_storno - 1,
f"Zaehler zurueckgesetzt ({f.cfg['rechnung_zaehler']} statt {zaehler_mit_storno})")
pruefe(os.path.exists(dritte), "die Rechnung 2026-003 steht unveraendert da")
pruefe(len([n for n in os.listdir(ordner) if n.endswith('.pdf')]) == 3,
"es liegen wieder genau 3 PDFs im Ordner")
f.destroy()
print("---")
print(f"Ordner: {ordner}")
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
sys.exit(1 if fehler else 0)