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
+3 -3
View File
@@ -345,13 +345,13 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
name="Zahlungserinnerung",
payment_deadline_days=14,
detail="Per E-Mail versandt",
fee="5.00",
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
assert reminder["status"] == "draft"
assert reminder["fee_item_id"] is None
assert reminder["fee_item_ids"] == []
repository.mark_reminder_sent(member.member_id, "claim-1", reminder["reminder_id"])
data, claim = repository.get_claim(member.member_id, "claim-1")
@@ -360,7 +360,7 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
assert claim_total(claim) == Decimal("105.00")
assert sent["status"] == "sent"
assert sent["payment_deadline"]
assert sent["fee_item_id"]
assert sent["fee_item_ids"]
assert data.reminders[0]["detail"] == "Per E-Mail versandt"
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent"
+1 -1
View File
@@ -64,7 +64,7 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
level=1,
name="Erste Mahnung",
payment_deadline_days=14,
fee="5.00",
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
generated = generate_and_send_reminder_mail(
+107 -2
View File
@@ -1,7 +1,9 @@
from datetime import date, timedelta
from decimal import Decimal
import pytest
from ccma.domain.contributions import claim_total
from ccma.domain.models import ContributionData
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError
@@ -42,7 +44,7 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
fee="0.00",
items=[],
)
findings = housekeeper.run(today=date(2026, 2, 10))
reminder_task = next(item for item in findings if item.code == "reminder_due")
@@ -155,7 +157,7 @@ def test_reminder_levels_cannot_be_skipped(tmp_path) -> None:
level=2,
name="Erste Mahnung",
payment_deadline_days=14,
fee="5.00",
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
@@ -173,3 +175,106 @@ def test_payment_resolves_open_reminder_task(tmp_path) -> None:
findings = housekeeper.run(today=date(2026, 2, 10))
assert not any(item.code == "reminder_due" for item in findings)
def test_reminder_can_carry_multiple_fee_items(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"},
{"description": "Bankgebühr", "amount": "3.50"},
],
)
assert draft["fee_item_ids"] == []
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert len(sent["fee_item_ids"]) == 2
data, claim = repository.get_claim(member.member_id, "claim-1")
descriptions = {item["description"] for item in claim["items"] if item["type"] == "fee"}
assert descriptions == {"Rücklastschriftgebühr", "Bankgebühr"}
assert claim_total(claim) == Decimal("108.50")
def test_reminder_items_require_a_description(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
with pytest.raises(RepositoryError, match="Beschreibung"):
repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": " ", "amount": "5.00"}],
)
def test_reminder_with_zero_amount_items_creates_no_claim_item(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=[{"description": "Mahngebühr", "amount": "0.00"}],
)
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert sent["fee_item_ids"] == []
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
def test_reminder_policy_round_trips_through_save_and_get(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.save_reminder_policy(
grace_days_after_due=10,
levels=[
{"name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 10},
{"name": "Mahnung", "fee": "7.50", "payment_deadline_days": 14},
],
standard_fee_items=[
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"},
],
)
policy = repository.get_reminder_policy()
assert policy["grace_days_after_due"] == 10
assert [level["level"] for level in policy["levels"]] == [1, 2]
assert policy["levels"][1]["name"] == "Mahnung"
assert policy["levels"][1]["fee"] == "7.50"
assert policy["standard_fee_items"] == [
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"}
]
def test_reminder_policy_rejects_invalid_input(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
with pytest.raises(RepositoryError, match="Mindestens eine Mahnstufe"):
repository.save_reminder_policy(grace_days_after_due=7, levels=[], standard_fee_items=[])
with pytest.raises(RepositoryError, match="benötigt einen Namen"):
repository.save_reminder_policy(
grace_days_after_due=7,
levels=[{"name": " ", "fee": "0.00", "payment_deadline_days": 14}],
standard_fee_items=[],
)
with pytest.raises(RepositoryError, match="nicht negativ"):
repository.save_reminder_policy(
grace_days_after_due=7,
levels=[{"name": "Mahnung", "fee": "-1.00", "payment_deadline_days": 14}],
standard_fee_items=[],
)