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
+53
View File
@@ -58,6 +58,59 @@ def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
assert generated.archive_path.parent.name == "Mahnungen"
def test_a_dunning_mail_asks_for_a_transfer_even_with_an_active_mandate(tmp_path):
"""A member is only dunned once the money did not arrive, so this claim has left
the direct-debit run -- "wir ziehen den Betrag ein, du musst nichts tun" would
contradict the very letter it accompanies."""
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
member.account_holder = "Reminder Test"
member.iban = "DE89370400440532013000"
member.mandate_reference = "MANDAT-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE98670505050038907751",
"bic": "MANSDE66XXX",
}
)
repository.save_organization(organization)
# The shipped template spells the bank details out; this one uses the ready-made
# paragraph, which is what the placeholder chooser offers for a dunning mail.
repository.save_mail_template(
"reminder",
subject="Mahnung: {{claim.title}}",
body="Hallo {{member.first_name}},\n\n{{payment.instructions}}\n\n{{signature}}",
)
reminder = repository.create_reminder_draft(
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
)
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
content = BytesParser(policy=default).parsebytes(
generated.export_path.read_bytes()
).get_content()
assert "Bitte überweise den Betrag" in content
assert "DE98670505050038907751" in content
assert "ziehen den Betrag" not in content
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
+46 -18
View File
@@ -32,6 +32,15 @@ def _overdue_claim_repository(tmp_path):
return repository, member
def _sepa_member(repository, member):
member.iban = "DE89370400440532013000"
member.mandate_reference = "MANDATE-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
return member
def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository)
@@ -136,11 +145,7 @@ def test_overdue_claim_and_reminder_finding_are_never_shown_at_once(tmp_path) ->
def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
member.iban = "DE89370400440532013000"
member.mandate_reference = "MANDATE-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
_sepa_member(repository, member)
housekeeper = Housekeeper(repository)
findings = housekeeper.run(today=date(2026, 2, 10))
@@ -152,13 +157,12 @@ def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
assert "SEPA" in sepa_finding.detail
def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(tmp_path) -> None:
def test_a_dunned_sepa_claim_continues_in_the_normal_dunning_process(tmp_path) -> None:
"""A bounced direct debit is dunned like any other claim: the money is expected as
a transfer from then on, so the "check the direct debit" notice must not come back
once the Rücklastschrift deadline has passed."""
repository, member = _overdue_claim_repository(tmp_path)
member.iban = "DE89370400440532013000"
member.mandate_reference = "MANDATE-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
_sepa_member(repository, member)
housekeeper = Housekeeper(repository)
draft = repository.create_reminder_draft(
@@ -177,20 +181,44 @@ def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(tmp_p
for item in housekeeper.run(today=deadline - timedelta(days=1))
if item.member_id == member.member_id
]
assert not any(item.code == "sepa_debit_overdue" for item in findings)
awaiting = next(item for item in findings if item.code == "sepa_debit_awaiting_deadline")
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
assert awaiting.severity == "info"
assert "Rücklastschrift" in awaiting.title
# Once the reminder's own deadline has passed without resolution, the plain SEPA
# follow-up notice returns so it doesn't just silently go quiet forever.
# Deadline gone by without payment: the next dunning level is due, not another
# "der Einzug muss geprüft werden".
findings = [
item for item in housekeeper.run(today=deadline + timedelta(days=1))
item
for item in housekeeper.run(today=deadline + timedelta(days=1))
if item.member_id == member.member_id
]
assert any(item.code == "sepa_debit_overdue" for item in findings)
assert not any(item.code == "sepa_debit_awaiting_deadline" for item in findings)
assert not any(item.code == "sepa_debit_overdue" for item in findings)
reminder_task = next(item for item in findings if item.code == "reminder_due")
assert "Erste Mahnung" in reminder_task.title
assert "Mahnstufe 2" in reminder_task.detail
def test_an_undunned_sepa_claim_still_asks_to_check_the_debit(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
_sepa_member(repository, member)
housekeeper = Housekeeper(repository)
findings = [
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
]
# A draft alone changes nothing -- only a reminder that actually went out takes the
# claim out of the direct-debit run.
repository.create_reminder_draft(
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
)
still_sepa = [
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
]
assert {item.code for item in findings} == {"sepa_debit_overdue"}
assert {item.code for item in still_sepa} == {"sepa_debit_overdue"}
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
+42
View File
@@ -63,6 +63,48 @@ def test_pending_debits_select_due_open_balance(tmp_path):
assert debits[0].claim_ids == ("due",)
def test_a_dunned_claim_is_not_collected_again(tmp_path):
"""The dunning letter asked the member to transfer the money -- pulling it by
direct debit anyway would contradict it (and can bounce a second time)."""
repository, member = _repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"due",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
)
# A draft alone is not a dunning: nothing has gone out yet.
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
assert [debit.claim_ids for debit in debits] == [("due",)]
assert warnings == []
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
# Nothing else is due in the window, so the member drops out of the run entirely
# -- and says why instead of vanishing silently.
assert debits == []
assert warnings == ["C3-42: „Mitgliedsbeitrag 2026“ wurde bereits gemahnt "
"Zahlung per Überweisung erwartet, kein erneuter Einzug."]
def test_a_dunned_claim_does_not_hold_back_the_members_other_claims(tmp_path):
repository, member = _repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id, "due", level=1, name="Rücklastschrift", payment_deadline_days=14
)
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
debits, warnings = pending_direct_debits(repository, due_until=date(2027, 12, 31))
assert [debit.claim_ids for debit in debits] == [("future",)]
assert debits[0].amount == Decimal("150.00")
assert len(warnings) == 1
def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
repository, member = _repository(tmp_path)
member.accepted_at = "2025-01-01"