Handle failed direct debits through the same reminder mechanism

A Rücklastschrift is still the member's own failure to ensure cover or
provide valid bank details, so it belongs in the same escalation ladder
as a regular reminder -- it now occupies whatever level the claim is next
due for, alongside a "Rücklastschrift" preset next to the existing
Mahnstufe presets.

Mahnung vorbereiten no longer lets the board pick a level (it's computed
automatically from what's already been sent, since the sequencing was
already server-enforced) and replaces the single fee field with a small
items table (Beschreibung + Betrag), so a reminder can carry several
charges at once -- e.g. Rücklastschriftgebühr plus Bankgebühr plus Porto
-- each landing on the claim as its own line item instead of one lump
sum under a generic label. The description field offers the configured
standard texts but stays free-editable.

Those standard texts and amounts, plus the dunning levels themselves
(name/fee/deadline), are now configurable from Optionen -> Mahnungen
instead of only being editable by hand-editing repository.json, which
was the case for the whole reminder policy until now. Cleaned up
reminder_fee/failed_debit_fee on contribution rules while at it -- both
were unused leftovers superseded by this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-15 03:13:04 +02:00
co-authored by Claude Sonnet 5
parent 46795fc110
commit a1719cad5e
9 changed files with 684 additions and 71 deletions
+168 -37
View File
@@ -19,6 +19,7 @@ from ccma.domain.contributions import (
decimal_value,
money_text,
payment_allocated_total,
reminder_items_total,
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.services.reminder_mail import generate_and_send_reminder_mail
@@ -348,7 +349,7 @@ class ClaimTab(ttk.Frame):
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
"",
"",
f"{reminder.get('fee', '0.00')} EUR",
f"{money_text(reminder_items_total(reminder))} EUR",
display_label(REMINDER_STATUS_LABELS, status),
" · ".join(
part
@@ -867,65 +868,195 @@ class AllocateCreditDialog(_Dialog):
class ReminderDialog(_Dialog):
"""Drafts the next reminder in the sequence for a claim. The level is computed
automatically (first not-yet-used level), and a "Vorlage" picks sensible defaults
for name/deadline/fee items -- the matching dunning-policy level if one exists,
a "Rücklastschrift" preset for a bounced direct debit (which occupies the same
level slot as a regular reminder, since it's still the member's own failure to
provide cover/valid bank details), or a blank template for manual entry. The fee
items table itself is always free to edit no matter which preset was picked."""
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnung vorbereiten", on_saved)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
policy = repository.get_configuration().get("reminder_policy") or {}
levels = policy.get("levels") or [
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}
]
self.definition_by_label = {
f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels
data, _claim = repository.get_claim(member_id, claim_id)
existing_levels = {
int(item.get("level", 0))
for item in data.reminders
if str(item.get("claim_id", "")) == claim_id
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
}
self.level_var = tk.StringVar(value=next(iter(self.definition_by_label)))
self.detail_var = tk.StringVar()
self.fee_var = tk.StringVar()
self.deadline_var = tk.StringVar()
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
self.level = max(existing_levels, default=0) + 1
self.policy = repository.get_reminder_policy()
self.items: list[dict[str, str]] = []
self.frame.columnconfigure(1, weight=1)
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
level_combo = ttk.Combobox(
ttk.Label(self.frame, text=str(self.level), style="Mono.TLabel").grid(
row=0, column=1, sticky="w", pady=5
)
self.preset_by_label = self._build_presets()
self.preset_var = tk.StringVar(value=next(iter(self.preset_by_label)))
ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
preset_combo = ttk.Combobox(
self.frame,
textvariable=self.level_var,
values=list(self.definition_by_label),
textvariable=self.preset_var,
values=list(self.preset_by_label),
state="readonly",
width=38,
)
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
preset_combo.grid(row=1, column=1, sticky="ew", pady=5)
preset_combo.bind("<<ComboboxSelected>>", lambda _event: self._apply_preset())
self.name_var = tk.StringVar()
ttk.Label(self.frame, text="Bezeichnung").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.name_var, width=45).grid(
row=2, column=1, sticky="ew", pady=5
)
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5)
ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12))
self.detail_var = tk.StringVar()
ttk.Label(self.frame, text="Details").grid(row=3, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(
row=3, column=1, sticky="ew", pady=5
)
self.deadline_var = tk.StringVar()
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
row=4, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=4, column=1, sticky="ew", pady=5)
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
ttk.Label(self.frame, text="Versandweg").grid(row=5, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox(
self.frame,
textvariable=self.channel_var,
values=list(REMINDER_CHANNEL_LABELS.values()),
state="readonly",
).grid(row=4, column=1, sticky="ew", pady=5)
self._load_definition()
self._buttons(5, self._save)
).grid(row=5, column=1, sticky="ew", pady=5)
def _load_definition(self) -> None:
definition = self.definition_by_label[self.level_var.get()]
self.fee_var.set(str(definition.get("fee", "0.00")))
self.deadline_var.set(str(definition.get("payment_deadline_days", 14)))
self._build_items_table(row=6)
self._apply_preset()
self._buttons(7, self._save)
def _build_presets(self) -> dict[str, dict]:
presets: dict[str, dict] = {}
matching = next((lvl for lvl in self.policy["levels"] if lvl["level"] == self.level), None)
if matching:
fee = decimal_value(matching["fee"])
presets[f"Stufe {self.level}: {matching['name']}"] = {
"name": matching["name"],
"payment_deadline_days": matching["payment_deadline_days"],
"items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [],
}
failed_debit_amount = self._default_amount_for("Rücklastschrift")
presets["Rücklastschrift"] = {
"name": "Rücklastschrift",
"payment_deadline_days": 14,
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
}
presets["Frei (eigene Eingabe)"] = {"name": "", "payment_deadline_days": 14, "items": []}
return presets
def _default_amount_for(self, needle: str) -> str:
for entry in self.policy["standard_fee_items"]:
if needle.casefold() in str(entry.get("description", "")).casefold():
return str(entry.get("default_amount", "0.00"))
return "0.00"
def _apply_preset(self) -> None:
preset = self.preset_by_label[self.preset_var.get()]
self.name_var.set(preset["name"])
self.deadline_var.set(str(preset["payment_deadline_days"]))
self.items = [dict(item) for item in preset["items"]]
self._refresh_items()
def _build_items_table(self, row: int) -> None:
table_frame = ttk.LabelFrame(self.frame, text="Positionen", padding=8)
table_frame.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=(8, 0))
table_frame.columnconfigure(0, weight=1)
self.items_tree = ttk.Treeview(
table_frame, columns=("description", "amount"), show="headings", height=4, selectmode="browse"
)
self.items_tree.heading("description", text="Beschreibung")
self.items_tree.heading("amount", text="Betrag")
self.items_tree.column("description", width=220, anchor="w")
self.items_tree.column("amount", width=100, anchor="w", stretch=False)
self.items_tree.grid(row=0, column=0, sticky="ew")
controls = ttk.Frame(table_frame)
controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
self.item_description_var = tk.StringVar()
description_combo = ttk.Combobox(
controls,
textvariable=self.item_description_var,
values=[str(entry.get("description", "")) for entry in self.policy["standard_fee_items"]],
width=22,
)
description_combo.pack(side="left", padx=(0, 8))
description_combo.bind("<<ComboboxSelected>>", lambda _event: self._prefill_item_amount())
self.item_amount_var = tk.StringVar()
ttk.Entry(controls, textvariable=self.item_amount_var, width=10).pack(side="left", padx=(0, 8))
ttk.Button(controls, text="Position hinzufügen", command=self._add_item).pack(
side="left", padx=(0, 8)
)
ttk.Button(controls, text="Position entfernen", command=self._remove_item).pack(side="left")
def _prefill_item_amount(self) -> None:
description = self.item_description_var.get()
for entry in self.policy["standard_fee_items"]:
if str(entry.get("description", "")) == description:
self.item_amount_var.set(str(entry.get("default_amount", "0.00")))
return
def _refresh_items(self) -> None:
self.items_tree.delete(*self.items_tree.get_children())
for index, item in enumerate(self.items):
self.items_tree.insert(
"", "end", iid=str(index), values=(item["description"], f"{item['amount']} EUR")
)
def _add_item(self) -> None:
description = self.item_description_var.get().strip()
if not description:
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
return
try:
amount = decimal_value(self.item_amount_var.get(), "Betrag")
except ValueError as exc:
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
return
self.items.append({"description": description, "amount": money_text(amount)})
self.item_description_var.set("")
self.item_amount_var.set("")
self._refresh_items()
def _remove_item(self) -> None:
selected = self.items_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
return
del self.items[int(selected[0])]
self._refresh_items()
def _save(self):
definition = self.definition_by_label[self.level_var.get()]
try:
deadline_days = int(self.deadline_var.get())
except ValueError:
messagebox.showerror(
"Ungültige Frist", "Die Zahlungsfrist muss eine ganze Zahl sein.", parent=self
)
return
try:
self.repository.create_reminder_draft(
self.member_id,
self.claim_id,
level=int(definition.get("level", 1)),
name=str(definition.get("name", "Mahnung")),
payment_deadline_days=int(self.deadline_var.get()),
level=self.level,
name=self.name_var.get(),
payment_deadline_days=deadline_days,
detail=self.detail_var.get(),
fee=self.fee_var.get(),
items=self.items,
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
)
except (ValueError, RepositoryError) as exc:
+238
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from ccma.config import AppConfig
from ccma.domain.contributions import decimal_value, money_text
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
from ccma.services.intervals import (
IntervalValidationError,
@@ -84,18 +85,21 @@ class OptionsDialog(tk.Toplevel):
member_numbers = ttk.Frame(notebook, padding=16)
organization = ttk.Frame(notebook, padding=16)
automation = ttk.Frame(notebook, padding=16)
reminders = ttk.Frame(notebook, padding=16)
changelog = ChangelogView(notebook)
notebook.add(paths, text="Pfade")
notebook.add(appearance, text="Darstellung")
notebook.add(member_numbers, text="Mitgliedsnummern")
notebook.add(organization, text="Verein / Absender")
notebook.add(automation, text="Hausmeister")
notebook.add(reminders, text="Mahnungen")
notebook.add(changelog, text="Changelog")
self._build_paths(paths)
self._build_appearance(appearance)
self._build_member_numbers(member_numbers)
self._build_organization(organization)
self._build_automation(automation)
self._build_reminders(reminders)
buttons = ttk.Frame(root)
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
@@ -336,6 +340,234 @@ class OptionsDialog(tk.Toplevel):
preview = f"UNGÜLTIG: {exc}"
self.number_preview_var.set(preview)
def _build_reminders(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
policy = self.repository.get_reminder_policy()
self.grace_days_var = tk.StringVar(value=str(policy["grace_days_after_due"]))
ttk.Label(parent, text="Kulanzfrist nach Fälligkeit (Tage)").grid(
row=0, column=0, sticky="w", pady=6
)
ttk.Spinbox(parent, from_=0, to=365, textvariable=self.grace_days_var, width=6).grid(
row=0, column=1, sticky="w", pady=6
)
ttk.Label(
parent,
text="Ab wann nach Fälligkeit die erste Mahnstufe fällig wird.",
style="Muted.TLabel",
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
levels_frame = ttk.LabelFrame(parent, text="Mahnstufen", padding=10)
levels_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(4, 0))
levels_frame.columnconfigure(0, weight=1)
self.levels = [dict(level) for level in policy["levels"]]
self.levels_tree = ttk.Treeview(
levels_frame, columns=("level", "name", "fee", "deadline"), show="headings", height=5
)
for key, title, width in (
("level", "Stufe", 55),
("name", "Name", 220),
("fee", "Gebühr", 90),
("deadline", "Frist (Tage)", 100),
):
self.levels_tree.heading(key, text=title)
self.levels_tree.column(key, width=width, anchor="w")
self.levels_tree.grid(row=0, column=0, sticky="ew")
self.levels_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_level())
level_controls = ttk.Frame(levels_frame)
level_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
self.level_name_var = tk.StringVar()
self.level_fee_var = tk.StringVar()
self.level_deadline_var = tk.StringVar()
ttk.Entry(level_controls, textvariable=self.level_name_var, width=22).pack(side="left", padx=(0, 6))
ttk.Entry(level_controls, textvariable=self.level_fee_var, width=8).pack(side="left", padx=(0, 6))
ttk.Entry(level_controls, textvariable=self.level_deadline_var, width=8).pack(
side="left", padx=(0, 6)
)
ttk.Button(level_controls, text="Übernehmen", command=self._apply_level).pack(
side="left", padx=(0, 6)
)
ttk.Button(level_controls, text="Hinzufügen", command=self._add_level).pack(side="left", padx=(0, 6))
ttk.Button(level_controls, text="Entfernen", command=self._remove_level).pack(side="left")
ttk.Label(
parent,
text="Stufen werden immer nach ihrer Reihenfolge in der Liste 1, 2, 3, … nummeriert.",
style="Muted.TLabel",
).grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))
items_frame = ttk.LabelFrame(parent, text="Standardpositionen (Beschreibung & Betrag)", padding=10)
items_frame.grid(row=4, column=0, columnspan=2, sticky="nsew", pady=(16, 0))
items_frame.columnconfigure(0, weight=1)
self.standard_items = [dict(item) for item in policy["standard_fee_items"]]
self.standard_items_tree = ttk.Treeview(
items_frame, columns=("description", "amount"), show="headings", height=5
)
self.standard_items_tree.heading("description", text="Beschreibung")
self.standard_items_tree.heading("amount", text="Standardbetrag")
self.standard_items_tree.column("description", width=260, anchor="w")
self.standard_items_tree.column("amount", width=120, anchor="w")
self.standard_items_tree.grid(row=0, column=0, sticky="ew")
self.standard_items_tree.bind(
"<<TreeviewSelect>>", lambda _event: self._load_selected_standard_item()
)
item_controls = ttk.Frame(items_frame)
item_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
self.standard_item_description_var = tk.StringVar()
self.standard_item_amount_var = tk.StringVar()
ttk.Entry(item_controls, textvariable=self.standard_item_description_var, width=26).pack(
side="left", padx=(0, 6)
)
ttk.Entry(item_controls, textvariable=self.standard_item_amount_var, width=10).pack(
side="left", padx=(0, 6)
)
ttk.Button(item_controls, text="Übernehmen", command=self._apply_standard_item).pack(
side="left", padx=(0, 6)
)
ttk.Button(item_controls, text="Hinzufügen", command=self._add_standard_item).pack(
side="left", padx=(0, 6)
)
ttk.Button(item_controls, text="Entfernen", command=self._remove_standard_item).pack(side="left")
ttk.Label(
parent,
text=(
"Diese Vorlagen stehen bei Mahnungen und Rücklastschriften als Positionen zur "
"Auswahl, z. B. Mahngebühr, Rücklastschriftgebühr, Bankgebühr oder Porto."
),
style="Muted.TLabel",
wraplength=560,
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(4, 0))
self._refresh_levels()
self._refresh_standard_items()
def _refresh_levels(self) -> None:
selected = self.levels_tree.selection()
self.levels_tree.delete(*self.levels_tree.get_children())
for index, level in enumerate(self.levels):
self.levels_tree.insert(
"",
"end",
iid=str(index),
values=(
index + 1,
level.get("name", ""),
f"{level.get('fee', '0.00')} EUR",
level.get("payment_deadline_days", 14),
),
)
if selected and self.levels_tree.exists(selected[0]):
self.levels_tree.selection_set(selected[0])
def _load_selected_level(self) -> None:
selected = self.levels_tree.selection()
if not selected:
return
level = self.levels[int(selected[0])]
self.level_name_var.set(level.get("name", ""))
self.level_fee_var.set(str(level.get("fee", "0.00")))
self.level_deadline_var.set(str(level.get("payment_deadline_days", 14)))
def _apply_level(self) -> None:
selected = self.levels_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
return
name = self.level_name_var.get().strip()
if not name:
messagebox.showerror("Name fehlt", "Bitte einen Namen angeben.", parent=self)
return
try:
fee = decimal_value(self.level_fee_var.get(), "Gebühr")
deadline = int(self.level_deadline_var.get())
except (ValueError, TypeError) as exc:
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
return
self.levels[int(selected[0])] = {
"name": name,
"fee": money_text(fee),
"payment_deadline_days": deadline,
}
self._refresh_levels()
def _add_level(self) -> None:
name = self.level_name_var.get().strip() or "Neue Mahnstufe"
try:
fee = decimal_value(self.level_fee_var.get() or "0", "Gebühr")
deadline = int(self.level_deadline_var.get() or "14")
except (ValueError, TypeError) as exc:
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
return
self.levels.append({"name": name, "fee": money_text(fee), "payment_deadline_days": deadline})
self._refresh_levels()
def _remove_level(self) -> None:
selected = self.levels_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
return
del self.levels[int(selected[0])]
self._refresh_levels()
def _refresh_standard_items(self) -> None:
selected = self.standard_items_tree.selection()
self.standard_items_tree.delete(*self.standard_items_tree.get_children())
for index, item in enumerate(self.standard_items):
self.standard_items_tree.insert(
"",
"end",
iid=str(index),
values=(item.get("description", ""), f"{item.get('default_amount', '0.00')} EUR"),
)
if selected and self.standard_items_tree.exists(selected[0]):
self.standard_items_tree.selection_set(selected[0])
def _load_selected_standard_item(self) -> None:
selected = self.standard_items_tree.selection()
if not selected:
return
item = self.standard_items[int(selected[0])]
self.standard_item_description_var.set(item.get("description", ""))
self.standard_item_amount_var.set(str(item.get("default_amount", "0.00")))
def _apply_standard_item(self) -> None:
selected = self.standard_items_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
return
description = self.standard_item_description_var.get().strip()
if not description:
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
return
try:
amount = decimal_value(self.standard_item_amount_var.get(), "Standardbetrag")
except ValueError as exc:
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
return
self.standard_items[int(selected[0])] = {
"description": description,
"default_amount": money_text(amount),
}
self._refresh_standard_items()
def _add_standard_item(self) -> None:
description = self.standard_item_description_var.get().strip() or "Neue Position"
try:
amount = decimal_value(self.standard_item_amount_var.get() or "0", "Standardbetrag")
except ValueError as exc:
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
return
self.standard_items.append({"description": description, "default_amount": money_text(amount)})
self._refresh_standard_items()
def _remove_standard_item(self) -> None:
selected = self.standard_items_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
return
del self.standard_items[int(selected[0])]
self._refresh_standard_items()
def _choose_store(self) -> None:
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
initial = current if current.is_dir() else current.parent
@@ -377,6 +609,7 @@ class OptionsDialog(tk.Toplevel):
anniversary_before = _parse_day_window(self.anniversary_before_var.get(), "Jubiläen vorher")
anniversary_after = _parse_day_window(self.anniversary_after_var.get(), "Jubiläen nachher")
anniversary_intervals = normalize_anniversary_intervals(self.anniversary_intervals_var.get())
grace_days_after_due = _parse_day_window(self.grace_days_var.get(), "Kulanzfrist")
except (ValueError, IntervalValidationError) as exc:
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
return
@@ -439,6 +672,11 @@ class OptionsDialog(tk.Toplevel):
self.repository.save_organization(
{key: variable.get() for key, variable in self.organization_vars.items()}
)
self.repository.save_reminder_policy(
grace_days_after_due=grace_days_after_due,
levels=self.levels,
standard_fee_items=self.standard_items,
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
return