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
+14 -1
View File
@@ -21,6 +21,7 @@ from ccma.domain.contributions import (
claim_status,
claim_total,
money_text,
reminder_items_total,
)
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member
@@ -293,6 +294,10 @@ def _template_values(
channel_labels = {"email": "E-Mail", "letter": "Brief", "personal": "Persönlich"}
status = str(reminder.get("status", "draft"))
channel = str(reminder.get("channel", ""))
reminder_items = reminder.get("items") or []
item_lines = [
f"{item.get('description', '')}: {item.get('amount', '0.00')} EUR" for item in reminder_items
]
values.update(
{
"reminder.id": str(reminder.get("reminder_id", "")),
@@ -305,11 +310,19 @@ def _template_values(
str(reminder.get("payment_deadline") or "")
),
"reminder.payment_deadline_days": str(reminder.get("payment_deadline_days", "")),
"reminder.fee": f"{reminder.get('fee', '0.00')} EUR",
"reminder.fee": f"{money_text(reminder_items_total(reminder))} EUR",
"reminder.items": "; ".join(item_lines),
"reminder.detail": str(reminder.get("detail", "")),
"reminder.channel": channel_labels.get(channel, channel),
}
)
repeats["reminder.items"] = [
{
"item.description": str(item.get("description", "")),
"item.amount": str(item.get("amount", "")),
}
for item in reminder_items
]
return values, repeats
+10 -1
View File
@@ -69,6 +69,15 @@ def reminder_mail_bytes(
bic = str(organization.get("bic", "")).strip()
payment_details = f"IBAN: {iban}" + (f"\nBIC: {bic}" if bic else "")
detail = str(reminder.get("detail", "")).strip()
fee_items = reminder.get("items") or []
fee_lines = (
"\n".join(
f"{item.get('description', 'Gebühr')}: {item.get('amount', '0.00')} Euro"
for item in fee_items
)
if fee_items
else "Mahngebühr: 0.00 Euro"
)
message.set_content(
"\n".join(
(
@@ -82,7 +91,7 @@ def reminder_mail_bytes(
f"{format_date_for_display(str(claim.get('due_date', '')))}",
f"Neue Zahlungsfrist: "
f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}",
f"Mahngebühr: {reminder.get('fee', '0.00')} Euro",
fee_lines,
f"Hinweis: {detail}" if detail else "",
"",
"Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:",