mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-26 07:15:20 +02:00
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:
co-authored by
Claude Sonnet 5
parent
46795fc110
commit
a1719cad5e
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user