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
+168
-37
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user