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: