Keep dunning a bounced debit out of the direct-debit track

Once the direct debit bounced and the board sent the Rücklastschrift reminder,
the claim is expected as a transfer by the deadline that letter states. Three
places still treated it as a claim the mandate covers, and the housekeeper's was
the one the board kept running into: after the reminder's deadline lapsed, the
finding went back to "Lastschrift überfällig -- Einzug prüfen, eine postalische
Mahnung ist hier nicht vorgesehen", for a claim that had just been dunned.

The rule now asks whether the claim was dunned before treating it as one for the
direct debit. If it was, it continues in the ordinary dunning sequence: the
running deadline shows as the usual "Frist läuft noch" note, and once that has
passed the next dunning level comes due. The SEPA-specific pending-reminder
detour that used to cover the deadline window is gone with it -- the ordinary
path reports the same thing.

The SEPA run now skips a dunned claim as well, instead of quietly collecting the
money the letter asked the member to transfer (which can bounce a second time,
with a second fee). The skip is reported like the incomplete mandates are, so
nothing disappears from the run without saying why; the dialog's wording is no
longer specific to mandates.

And a dunning mail asks for a transfer even from a member with an active
mandate. The shipped template spells the bank details out, but the ready-made
{{payment.instructions}} paragraph, offered by the template editor for exactly
this mail, told them "wir ziehen den Betrag ein, du musst nichts weiter tun" --
in the letter demanding payment.

Reverting the sent reminder is what puts the claim back into the direct-debit
run; the read of "dunned" is a sent reminder, not a draft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-09-05 00:16:26 +02:00
co-authored by Claude Opus 5
parent 31c1646571
commit f80b17c443
11 changed files with 218 additions and 87 deletions
+2 -1
View File
@@ -42,7 +42,8 @@
"Die Texte aller erzeugten E-Mails (Willkommen, Mahnung, SEPA-Vorabinformation) stecken jetzt in bearbeitbaren Vorlagen: Sie werden als Standard mitgeliefert, landen beim ersten Start im Mitglieder-Store unter templates/mail/ und können unter Optionen → E-Mail-Vorlagen mit Betreff, Text und Platzhaltern angepasst oder auf den Standardtext zurückgesetzt werden.",
"Für neue Mitglieder kann direkt aus der Mitgliederakte eine Willkommens- und Erstrechnungsmail erzeugt werden: Sie begrüßt das Mitglied, nennt Mitgliedsnummer und Beginn der Mitgliedschaft und stellt die noch offenen Forderungen (Aufnahmegebühr und erster Beitrag) mit Zahlungshinweis zusammen; einzelne Forderungen lassen sich abwählen und die fertige Mail vorab ansehen.",
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart.",
"Über „Datenüberprüfung anfragen“ in der Mitgliederliste kann eine Bitte um Datenprüfung an beliebig viele Mitglieder auf einmal verschickt werden: Jede Mail listet den eigenen Datensatz des Empfängers auf (fehlende Angaben werden als solche ausgewiesen, die IBAN nur mit den letzten vier Stellen), vorausgewählt sind alle laufenden Mitgliedschaften mit E-Mail-Adresse. Text und Betreff stammen aus der neuen Vorlage „Datenüberprüfung“; jede Mail wird in der Mitgliederakte archiviert und in der Chronik vermerkt. Die Mail nennt außerdem die Zahlweise und den Verwendungszweck, den das Mitglied bei Überweisungen angeben soll (Mitgliedsnummer und voller Name)."
"Über „Datenüberprüfung anfragen“ in der Mitgliederliste kann eine Bitte um Datenprüfung an beliebig viele Mitglieder auf einmal verschickt werden: Jede Mail listet den eigenen Datensatz des Empfängers auf (fehlende Angaben werden als solche ausgewiesen, die IBAN nur mit den letzten vier Stellen), vorausgewählt sind alle laufenden Mitgliedschaften mit E-Mail-Adresse. Text und Betreff stammen aus der neuen Vorlage „Datenüberprüfung“; jede Mail wird in der Mitgliederakte archiviert und in der Chronik vermerkt. Die Mail nennt außerdem die Zahlweise und den Verwendungszweck, den das Mitglied bei Überweisungen angeben soll (Mitgliedsnummer und voller Name).",
"Eine Forderung, für die bereits eine Mahnung bzw. Rücklastschrift-Zahlungserinnerung versandt wurde, wird nicht mehr als Lastschrift behandelt: Der Hausmeister meldet nach Ablauf der Zahlungsfrist die nächste Mahnstufe statt weiterhin „Einzug prüfen, eine Mahnung ist hier nicht vorgesehen“, der SEPA-Lauf zieht die Forderung nicht erneut ein (und weist die übersprungene Position aus) und die Mahnungsmail bittet um Überweisung, auch wenn das Lastschriftmandat weiterhin aktiv ist. Wird der Versand einer Mahnung zurückgenommen, steht die Forderung wieder für den Einzug bereit."
]
},
{
+16
View File
@@ -160,6 +160,22 @@ def reminder_items_total(reminder: dict[str, Any]) -> Decimal:
)
def claim_was_dunned(data: ContributionData, claim: dict[str, Any]) -> bool:
"""True once a reminder for this claim has actually gone out. Such a claim has
left the direct-debit track: the collection failed (or never happened), the member
was asked in writing to pay it, and the money is expected as a transfer by the
stated deadline. Quietly collecting it a second time is exactly what the dunning
letter says will not happen, so both the SEPA run and the housekeeper ask this
before treating a claim as one the mandate still covers."""
claim_id = str(claim.get("claim_id", ""))
if not claim_id:
return False
return any(
str(reminder.get("claim_id", "")) == claim_id and str(reminder.get("status", "")) == "sent"
for reminder in data.reminders
)
def contribution_override_covers_month(override: dict[str, Any], month: str) -> bool:
valid_from = str(override.get("valid_from", ""))
valid_until = str(override.get("valid_until", "") or "")
+12 -57
View File
@@ -1,6 +1,6 @@
from datetime import date, timedelta
from ccma.domain.contributions import claim_balance, claim_status, money_text
from ccma.domain.contributions import claim_balance, claim_status, claim_was_dunned, money_text
from ccma.rules.api import RuleAction, RuleContext, task
RULE_ID = "reminder-due"
@@ -18,12 +18,12 @@ DEFAULT_POLICY = {
def evaluate(context: RuleContext) -> list[RuleAction]:
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
grace period, then either the postal dunning escalation or -- for members paying by
SEPA direct debit, where sending a dunning letter makes no sense -- a distinct
"check the direct debit" notice. Falls back to the plain overdue notice whenever no
escalation applies (dunning hold active, or between reminder levels), so there is
always exactly one finding for an overdue claim, never both an overdue notice and a
reminder notice at once."""
grace period, then either the postal dunning escalation or -- for a claim the
member's mandate still covers, where sending a dunning letter makes no sense -- a
distinct "check the direct debit" notice. Falls back to the plain overdue notice
whenever no escalation applies (dunning hold active, or between reminder levels),
so there is always exactly one finding for an overdue claim, never both an overdue
notice and a reminder notice at once."""
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
grace_days = int(policy.get("grace_days_after_due", 7))
@@ -48,8 +48,11 @@ def evaluate(context: RuleContext) -> list[RuleAction]:
escalation = None
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
if context.member.mandate_active:
escalation = _sepa_escalation(context, claim, claim_id, days_overdue)
# A claim that was already dunned is no longer collected by direct debit --
# the member was asked to transfer it, so it continues in the ordinary
# dunning sequence even though the mandate itself is still active.
if context.member.mandate_active and not claim_was_dunned(context.contributions, claim):
escalation = _sepa_action(context, claim, claim_id, days_overdue)
else:
escalation = _reminder_action(context, claim, claim_id, levels, policy)
actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
@@ -72,13 +75,6 @@ def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_
)
def _sepa_escalation(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
pending = _latest_pending_reminder(context.contributions.reminders, claim_id, context.today)
if pending:
return _sepa_awaiting_action(context, claim, claim_id, *pending)
return _sepa_action(context, claim, claim_id, days_overdue)
def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
balance = money_text(claim_balance(context.contributions, claim))
return task(
@@ -97,47 +93,6 @@ def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int)
)
def _sepa_awaiting_action(
context: RuleContext, claim, claim_id: str, reminder: dict, trigger_date: date
) -> RuleAction:
"""A Rücklastschrift-Mahnung (or any reminder) was already sent for this SEPA
claim and its own deadline hasn't expired yet -- staff already engaged, so this
must not keep nagging "check the direct debit" as if nothing had happened."""
name = str(reminder.get("name") or "Rücklastschrift")
balance = money_text(claim_balance(context.contributions, claim))
return task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"{claim_id}:sepa-awaiting",
severity="info",
code="sepa_debit_awaiting_deadline",
title=f"{context.member.display_name}: {name} versandt, Rücklastschriftklärung läuft",
detail=(
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
),
due_date=trigger_date,
)
def _latest_pending_reminder(
reminders: list[dict], claim_id: str, today: date
) -> tuple[dict, date] | None:
best: tuple[dict, date] | None = None
for item in reminders:
if str(item.get("claim_id", "")) != claim_id or str(item.get("status", "")) != "sent":
continue
try:
deadline = date.fromisoformat(str(item.get("payment_deadline", "")))
except ValueError:
continue
if today > deadline:
continue
if best is None or deadline > best[1]:
best = (item, deadline)
return best
def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None:
reminders = [
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
+5 -2
View File
@@ -38,11 +38,14 @@ def payment_instructions(
*,
due_date: str,
reference: str,
expect_transfer: bool = False,
) -> str:
"""Ready-made payment paragraph: members with an active mandate are told the
money is collected, everyone else gets the club's bank details."""
money is collected, everyone else gets the club's bank details. `expect_transfer`
overrides that for a claim the mandate no longer covers -- a dunned claim is
expected as a transfer even though the member still has a mandate."""
organization = organization if isinstance(organization, dict) else {}
if member.mandate_active:
if member.mandate_active and not expect_transfer:
mandate = member.mandate_reference.strip()
mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else ""
return (
+9 -1
View File
@@ -96,8 +96,16 @@ def reminder_mail_bytes(
),
"reminder.fee_total": money_text(reminder_items_total(reminder)),
"payment.reference": reference,
# A dunning mail only goes out because the money did not arrive, so it
# asks for a transfer even from a member with an active mandate: this
# claim has left the direct-debit run and telling them "we collect it,
# you need not do anything" would contradict the letter it carries.
"payment.instructions": payment_instructions(
member, organization, due_date=payment_deadline, reference=reference
member,
organization,
due_date=payment_deadline,
reference=reference,
expect_transfer=True,
),
}
)
+20 -6
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from ccma.domain.contributions import claim_balance
from ccma.domain.contributions import claim_balance, claim_was_dunned
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
@@ -63,6 +63,7 @@ def pending_direct_debits(
for member in repository.list_members():
if not member.mandate_active:
continue
member_label = member.member_number or member.display_name
missing = [
label
for value, label in (
@@ -73,13 +74,13 @@ def pending_direct_debits(
if not str(value).strip()
]
if missing:
warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.")
warnings.append(f"{member_label}: {', '.join(missing)} fehlt.")
continue
try:
validate_iban(member.iban)
date.fromisoformat(member.mandate_signed_at)
except (RepositoryError, ValueError) as exc:
warnings.append(f"{member.member_number or member.display_name}: {exc}")
warnings.append(f"{member_label}: {exc}")
continue
data = repository.get_contributions(member.member_id)
selected = []
@@ -92,9 +93,22 @@ def pending_direct_debits(
except ValueError:
continue
balance = claim_balance(data, claim)
if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0:
selected.append(claim)
amount += balance
if not ((due_from is None or claim_due >= due_from) and claim_due <= due_until):
continue
if balance <= 0:
continue
if claim_was_dunned(data, claim):
# The debit for this claim already bounced and the member was dunned:
# the letter told them to transfer the money themselves, so collecting
# it behind their back would contradict it (and can bounce again, with
# another fee). Reverting the sent reminder puts it back in the run.
warnings.append(
f"{member_label}: „{claim.get('title') or 'Forderung'}“ wurde bereits "
"gemahnt Zahlung per Überweisung erwartet, kein erneuter Einzug."
)
continue
selected.append(claim)
amount += balance
if not selected:
continue
titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected]
+4 -2
View File
@@ -154,7 +154,9 @@ class SepaExportDialog(tk.Toplevel):
)
self._update_summary()
self.warning_var.set(
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else ""
f"{len(self.warnings)} Position(en) übersprungen Details beim Export."
if self.warnings
else ""
)
def _selected_debits(self) -> list:
@@ -185,7 +187,7 @@ class SepaExportDialog(tk.Toplevel):
)
return None
if self.warnings and not messagebox.askyesno(
"Unvollständige Mandate",
"Übersprungene Positionen",
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
parent=self,
):