Allow editing reminders (incl. undoing "sent") and prefill a Rücklastschrift reason

A generated/sent Mahnung email couldn't be corrected before: sent reminders were
locked, so a wording mistake meant living with it. Add edit_reminder_draft() for
draft/generated reminders and revert_reminder_sent() to safely undo a "sent"
reminder (removing the fee items it booked, refusing if a payment already covers
them or a higher Mahnstufe already exists) so it can be edited and resent.

ReminderDialog now supports an edit mode (reminder=... prefills name/detail/
items/deadline/channel, level stays fixed) and claim_tab gained a "Mahnung
bearbeiten" button that reverts-then-edits for sent reminders automatically,
asking for confirmation first since it un-books the fee.

The Rücklastschrift preset now also prefills "Details" with a short explanation
("Leider konnte die Lastschrift ... nicht eingelöst werden.") since that field
goes straight into the reminder email -- the board can append the concrete
reason (mangels Deckung, Konto ungültig, ...) right there instead of the field
starting empty.

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 f3bdffd347
commit 186af530b0
3 changed files with 322 additions and 23 deletions
+93
View File
@@ -17,6 +17,8 @@ from ccma.domain.contributions import (
CONTRIBUTION_OVERRIDE_KIND_LABELS, CONTRIBUTION_OVERRIDE_KIND_LABELS,
allocated_total, allocated_total,
claim_balance, claim_balance,
claim_items,
claim_settled_total,
claim_total, claim_total,
contribution_override_ranges_overlap, contribution_override_ranges_overlap,
credit_allocated_total, credit_allocated_total,
@@ -2108,6 +2110,97 @@ class MemberRepository:
references={"claim_id": claim_id, "reminder_id": reminder_id}, references={"claim_id": claim_id, "reminder_id": reminder_id},
) )
def edit_reminder_draft(
self,
member_id: str,
claim_id: str,
reminder_id: str,
*,
name: str,
payment_deadline_days: int,
detail: str = "",
items: list[dict[str, str]] | None = None,
channel: str = "email",
) -> dict:
try:
normalized_items = _normalize_reminder_items(items)
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if payment_deadline_days < 1 or payment_deadline_days > 365:
raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
data, _claim = self.get_claim(member_id, claim_id)
reminder = self._find_reminder(data, claim_id, reminder_id)
if str(reminder.get("status", "draft")) not in {"draft", "generated"}:
raise RepositoryError("Nur ein Entwurf kann bearbeitet werden.")
reminder["name"] = name.strip() or reminder["name"]
reminder["detail"] = detail.strip()
reminder["channel"] = channel.strip() or "email"
reminder["payment_deadline_days"] = payment_deadline_days
reminder["items"] = normalized_items
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="reminder_draft_edited",
summary=f"Mahnungsentwurf bearbeitet: {reminder['name']}",
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
data={"fee": money_text(reminder_items_total(reminder))},
)
return reminder
def revert_reminder_sent(self, member_id: str, claim_id: str, reminder_id: str) -> dict:
"""Undoes mark_reminder_sent: removes the fee items it booked onto the claim
and puts the reminder back into "draft" so its text/items can be edited and it
can be sent again -- e.g. after generating a Mahnung email and noticing the
wording needs a fix. Refuses if a later Mahnstufe already exists (would break
the level ordering) or if payments already booked against the claim would no
longer fit once the fee is removed."""
data, claim = self.get_claim(member_id, claim_id)
reminder = self._find_reminder(data, claim_id, reminder_id)
if str(reminder.get("status", "")) != "sent":
raise RepositoryError("Nur eine bereits versandte Mahnung kann zurückgesetzt werden.")
if any(
str(item.get("claim_id", "")) == claim_id
and int(item.get("level", 0)) > int(reminder.get("level", 0))
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
for item in data.reminders
):
raise RepositoryError(
"Es existiert bereits eine höhere Mahnstufe; diese Mahnung kann nicht "
"zurückgesetzt werden."
)
fee_item_ids = set(reminder.get("fee_item_ids") or [])
if fee_item_ids:
removed_total = sum(
(
decimal_value(item.get("amount", "0"))
for item in claim_items(claim)
if str(item.get("item_id", "")) in fee_item_ids
),
Decimal("0"),
)
new_total = claim_total(claim) - removed_total
if claim_settled_total(data, claim) > new_total:
raise RepositoryError(
"Für diese Forderung wurden bereits Zahlungen verbucht, die die "
"Mahngebühr abdecken; der Versand kann nicht zurückgesetzt werden."
)
claim["items"] = [
item for item in claim_items(claim) if str(item.get("item_id", "")) not in fee_item_ids
]
claim["amount"] = money_text(claim_total(claim))
reminder["status"] = "draft"
reminder["sent_at"] = None
reminder["payment_deadline"] = None
reminder["fee_item_ids"] = []
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="reminder_sent_reverted",
summary=f"Versand zurückgesetzt: {reminder.get('name', '')}",
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
)
return reminder
def register_reminder_document( def register_reminder_document(
self, self,
member_id: str, member_id: str,
+74 -4
View File
@@ -167,6 +167,10 @@ class ClaimTab(ttk.Frame):
state="disabled", state="disabled",
) )
self.email_reminder_button.pack(side="right", padx=(8, 0)) self.email_reminder_button.pack(side="right", padx=(8, 0))
self.edit_reminder_button = ttk.Button(
buttons, text="Mahnung bearbeiten", command=self._edit_reminder, state="disabled"
)
self.edit_reminder_button.pack(side="right", padx=(8, 0))
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right") ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack( ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
side="right", padx=(0, 8) side="right", padx=(0, 8)
@@ -438,6 +442,9 @@ class ClaimTab(ttk.Frame):
self.send_reminder_button.configure(state=state) self.send_reminder_button.configure(state=state)
self.email_reminder_button.configure(state=state) self.email_reminder_button.configure(state=state)
self.discard_reminder_button.configure(state=state) self.discard_reminder_button.configure(state=state)
# Sent reminders can also be edited (revert-then-edit), just not cancelled ones.
can_edit = bool(reminder and str(reminder.get("status", "draft")) != "cancelled")
self.edit_reminder_button.configure(state="normal" if can_edit else "disabled")
def _email_reminder(self) -> None: def _email_reminder(self) -> None:
reminder = self._selected_reminder() reminder = self._selected_reminder()
@@ -467,6 +474,32 @@ class ClaimTab(ttk.Frame):
return return
self._changed() self._changed()
def _edit_reminder(self) -> None:
reminder = self._selected_reminder()
if not reminder:
return
if str(reminder.get("status", "")) == "sent":
if not messagebox.askyesno(
"Versand zurücksetzen",
"Diese Mahnung wurde bereits versandt. Zum Bearbeiten wird der Versand "
"zurückgesetzt (eine bereits gebuchte Mahngebühr wird von der Forderung "
"entfernt und die Zahlungsfrist gelöscht) -- anschließend muss die Mahnung "
"erneut versandt werden. Fortfahren?",
parent=self,
):
return
try:
reminder = self.repository.revert_reminder_sent(
self.member_id, self.claim_id, str(reminder["reminder_id"])
)
except RepositoryError as exc:
messagebox.showerror("Versand konnte nicht zurückgesetzt werden", str(exc), parent=self)
return
self.refresh()
ReminderDialog(
self, self.repository, self.member_id, self.claim_id, self._changed, reminder=reminder
)
def _discard_reminder(self) -> None: def _discard_reminder(self) -> None:
reminder = self._selected_reminder() reminder = self._selected_reminder()
if not reminder or not messagebox.askyesno( if not reminder or not messagebox.askyesno(
@@ -876,13 +909,17 @@ class ReminderDialog(_Dialog):
provide cover/valid bank details), or a blank template for manual entry. The fee 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.""" 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): def __init__(self, master, repository, member_id, claim_id, on_saved, *, reminder: dict | None = None):
super().__init__(master, "Mahnung vorbereiten", on_saved) super().__init__(master, "Mahnung bearbeiten" if reminder else "Mahnung vorbereiten", on_saved)
# Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table # Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table
# of freely-editable fee items whose content varies a lot -- make it resizable # of freely-editable fee items whose content varies a lot -- make it resizable
# so a long/multi-item Mahnung doesn't get cramped. # so a long/multi-item Mahnung doesn't get cramped.
self.resizable(True, True) self.resizable(True, True)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
self.editing_reminder = reminder
if reminder:
self.level = int(reminder.get("level", 1))
else:
data, _claim = repository.get_claim(member_id, claim_id) data, _claim = repository.get_claim(member_id, claim_id)
existing_levels = { existing_levels = {
int(item.get("level", 0)) int(item.get("level", 0))
@@ -902,7 +939,10 @@ class ReminderDialog(_Dialog):
) )
self.preset_by_label = self._build_presets() self.preset_by_label = self._build_presets()
self.preset_var = tk.StringVar(value=next(iter(self.preset_by_label))) default_preset = (
next(reversed(self.preset_by_label)) if reminder else next(iter(self.preset_by_label))
)
self.preset_var = tk.StringVar(value=default_preset)
ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
preset_combo = ttk.Combobox( preset_combo = ttk.Combobox(
self.frame, self.frame,
@@ -942,6 +982,16 @@ class ReminderDialog(_Dialog):
).grid(row=5, column=1, sticky="ew", pady=5) ).grid(row=5, column=1, sticky="ew", pady=5)
self._build_items_table(row=6) self._build_items_table(row=6)
if reminder:
self.name_var.set(str(reminder.get("name", "")))
self.detail_var.set(str(reminder.get("detail", "")))
self.deadline_var.set(str(reminder.get("payment_deadline_days", 14)))
self.channel_var.set(
display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", "email")))
)
self.items = [dict(item) for item in reminder.get("items") or []]
self._refresh_items()
else:
self._apply_preset() self._apply_preset()
self._buttons(7, self._save) self._buttons(7, self._save)
# Size to fit the fully built content (incl. the preset-populated items table) # Size to fit the fully built content (incl. the preset-populated items table)
@@ -963,16 +1013,23 @@ class ReminderDialog(_Dialog):
fee = decimal_value(matching["fee"]) fee = decimal_value(matching["fee"])
presets[f"Stufe {self.level}: {matching['name']}"] = { presets[f"Stufe {self.level}: {matching['name']}"] = {
"name": matching["name"], "name": matching["name"],
"detail": "",
"payment_deadline_days": matching["payment_deadline_days"], "payment_deadline_days": matching["payment_deadline_days"],
"items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [], "items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [],
} }
failed_debit_amount = self._default_amount_for("Rücklastschrift") failed_debit_amount = self._default_amount_for("Rücklastschrift")
presets[f"Stufe {self.level}: Rücklastschrift"] = { presets[f"Stufe {self.level}: Rücklastschrift"] = {
"name": "Rücklastschrift", "name": "Rücklastschrift",
# Prefilled so the member sees why they're being charged; the board can
# still append/edit a concrete reason (mangels Deckung, Konto ungültig, ...)
# right here since "Details" goes straight into the reminder email as-is.
"detail": "Leider konnte die Lastschrift von Ihrem Konto nicht eingelöst werden.",
"payment_deadline_days": 14, "payment_deadline_days": 14,
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}], "items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
} }
presets["Frei (eigene Eingabe)"] = {"name": "", "payment_deadline_days": 14, "items": []} presets["Frei (eigene Eingabe)"] = {
"name": "", "detail": "", "payment_deadline_days": 14, "items": []
}
return presets return presets
def _default_amount_for(self, needle: str) -> str: def _default_amount_for(self, needle: str) -> str:
@@ -984,6 +1041,7 @@ class ReminderDialog(_Dialog):
def _apply_preset(self) -> None: def _apply_preset(self) -> None:
preset = self.preset_by_label[self.preset_var.get()] preset = self.preset_by_label[self.preset_var.get()]
self.name_var.set(preset["name"]) self.name_var.set(preset["name"])
self.detail_var.set(preset["detail"])
self.deadline_var.set(str(preset["payment_deadline_days"])) self.deadline_var.set(str(preset["payment_deadline_days"]))
self.items = [dict(item) for item in preset["items"]] self.items = [dict(item) for item in preset["items"]]
self._refresh_items() self._refresh_items()
@@ -1112,6 +1170,18 @@ class ReminderDialog(_Dialog):
) )
return return
try: try:
if self.editing_reminder:
self.repository.edit_reminder_draft(
self.member_id,
self.claim_id,
str(self.editing_reminder["reminder_id"]),
name=self.name_var.get(),
payment_deadline_days=deadline_days,
detail=self.detail_var.get(),
items=self.items,
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
)
else:
self.repository.create_reminder_draft( self.repository.create_reminder_draft(
self.member_id, self.member_id,
self.claim_id, self.claim_id,
+136
View File
@@ -281,6 +281,142 @@ def test_reminder_policy_rejects_invalid_input(tmp_path) -> None:
) )
def test_edit_reminder_draft_updates_fields_but_not_level(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[],
)
edited = repository.edit_reminder_draft(
member.member_id,
"claim-1",
draft["reminder_id"],
name="Erste Mahnung",
payment_deadline_days=10,
detail="Bitte um Ausgleich.",
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
assert edited["level"] == 1
assert edited["name"] == "Erste Mahnung"
assert edited["payment_deadline_days"] == 10
assert edited["detail"] == "Bitte um Ausgleich."
assert edited["items"] == [{"description": "Mahngebühr", "amount": "5.00"}]
def test_edit_reminder_draft_rejects_already_sent_reminder(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[],
)
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
with pytest.raises(RepositoryError, match="Nur ein Entwurf"):
repository.edit_reminder_draft(
member.member_id,
"claim-1",
draft["reminder_id"],
name="Andere Bezeichnung",
payment_deadline_days=14,
)
def test_revert_reminder_sent_removes_booked_fee_and_reopens_draft(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
)
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert len(sent["fee_item_ids"]) == 1
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("105.00")
reverted = repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert reverted["status"] == "draft"
assert reverted["sent_at"] is None
assert reverted["payment_deadline"] is None
assert reverted["fee_item_ids"] == []
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
descriptions = {item["description"] for item in claim["items"]}
assert "Rücklastschriftgebühr" not in descriptions
# Now editable and re-sendable, e.g. after fixing the wording.
repository.edit_reminder_draft(
member.member_id,
"claim-1",
draft["reminder_id"],
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "7.50"}],
)
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("107.50")
def test_revert_reminder_sent_rejects_when_higher_level_exists(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
first = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[],
)
repository.mark_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
repository.create_reminder_draft(
member.member_id,
"claim-1",
level=2,
name="Erste Mahnung",
payment_deadline_days=14,
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
with pytest.raises(RepositoryError, match="höhere Mahnstufe"):
repository.revert_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
def test_revert_reminder_sent_rejects_when_payment_already_covers_fee(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
)
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-02-10",
amount="105.00",
allocation_amount="105.00",
)
with pytest.raises(RepositoryError, match="bereits Zahlungen verbucht"):
repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None: def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None:
repository = MemberRepository(tmp_path) repository = MemberRepository(tmp_path)
repository.initialize() repository.initialize()