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,
allocated_total,
claim_balance,
claim_items,
claim_settled_total,
claim_total,
contribution_override_ranges_overlap,
credit_allocated_total,
@@ -2108,6 +2110,97 @@ class MemberRepository:
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(
self,
member_id: str,