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
+136 -26
View File
@@ -25,6 +25,7 @@ from ccma.domain.contributions import (
materialize_claim_items,
money_text,
payment_allocated_total,
reminder_items_total,
)
from ccma.domain.dates import (
DateValidationError,
@@ -65,8 +66,6 @@ DEFAULT_CONTRIBUTION_RULES = [
"entry_proration": {"mode": "monthly", "started_month": "included"},
"first_payment_due_days_after_acceptance": 28,
"issue_days_before_due": 30,
"reminder_fee": "5.00",
"failed_debit_fee": "5.00",
},
{
"rule_id": "standard-2022",
@@ -80,8 +79,6 @@ DEFAULT_CONTRIBUTION_RULES = [
"entry_proration": {"mode": "monthly", "started_month": "included"},
"first_payment_due_days_after_acceptance": 28,
"issue_days_before_due": 30,
"reminder_fee": "5.00",
"failed_debit_fee": "5.00",
},
]
@@ -129,6 +126,14 @@ DEFAULT_CONFIGURATION = {
"payment_deadline_days": 14,
},
],
# Reusable description/amount presets offered as rows when recording a
# reminder or a failed direct debit; boards can add/edit/remove freely.
"standard_fee_items": [
{"description": "Mahngebühr", "default_amount": "5.00"},
{"description": "Rücklastschriftgebühr", "default_amount": "5.00"},
{"description": "Bankgebühr", "default_amount": "0.00"},
{"description": "Porto", "default_amount": "0.00"},
],
},
"contribution_rules": DEFAULT_CONTRIBUTION_RULES,
}
@@ -1975,17 +1980,15 @@ class MemberRepository:
name: str,
payment_deadline_days: int,
detail: str = "",
fee: str = "0",
items: list[dict[str, str]] | None = None,
channel: str = "email",
) -> dict:
if level < 1:
raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.")
try:
selected_fee = decimal_value(fee, "Mahngebühr")
normalized_items = _normalize_reminder_items(items)
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if selected_fee < 0:
raise RepositoryError("Die Mahngebühr darf nicht negativ sein.")
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)
@@ -2025,8 +2028,8 @@ class MemberRepository:
"payment_deadline_days": payment_deadline_days,
"payment_deadline": None,
"balance_snapshot": money_text(claim_balance(data, claim)),
"fee": money_text(selected_fee),
"fee_item_id": None,
"items": normalized_items,
"fee_item_ids": [],
"document": {"path": "", "sha256": ""},
}
data.reminders.append(reminder)
@@ -2036,7 +2039,10 @@ class MemberRepository:
event_type="reminder_draft_created",
summary=f"Mahnungsentwurf erstellt: {reminder['name']}",
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
data={"fee": reminder["fee"], "balance_snapshot": reminder["balance_snapshot"]},
data={
"fee": money_text(reminder_items_total(reminder)),
"balance_snapshot": reminder["balance_snapshot"],
},
)
return reminder
@@ -2048,20 +2054,27 @@ class MemberRepository:
if claim_balance(data, claim) <= 0:
raise RepositoryError("Die Forderung hat keinen offenen Betrag mehr.")
now = datetime.now().astimezone()
selected_fee = decimal_value(reminder.get("fee", "0"), "Mahngebühr")
if selected_fee > 0 and not reminder.get("fee_item_id"):
item = {
"item_id": str(uuid4()),
"type": "fee",
"description": f"Mahngebühr Stufe {reminder.get('level', '')}",
"quantity": "1.00",
"unit_price": money_text(selected_fee),
"amount": money_text(selected_fee),
"created_at": now.isoformat(timespec="seconds"),
}
materialize_claim_items(claim).append(item)
claim["amount"] = money_text(claim_total(claim))
reminder["fee_item_id"] = item["item_id"]
if reminder.get("items") and not reminder.get("fee_item_ids"):
fee_item_ids = []
for entry in reminder["items"]:
amount = decimal_value(entry.get("amount", "0"))
if amount <= 0:
continue
description = str(entry.get("description")) or f"Mahngebühr Stufe {reminder.get('level', '')}"
item = {
"item_id": str(uuid4()),
"type": "fee",
"description": description,
"quantity": "1.00",
"unit_price": money_text(amount),
"amount": money_text(amount),
"created_at": now.isoformat(timespec="seconds"),
}
materialize_claim_items(claim).append(item)
fee_item_ids.append(item["item_id"])
if fee_item_ids:
claim["amount"] = money_text(claim_total(claim))
reminder["fee_item_ids"] = fee_item_ids
reminder["status"] = "sent"
reminder["sent_at"] = now.isoformat(timespec="seconds")
reminder["payment_deadline"] = (
@@ -2073,7 +2086,10 @@ class MemberRepository:
event_type="reminder_sent",
summary=f"Mahnung als versandt markiert: {reminder.get('name', '')}",
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
data={"fee": money_text(selected_fee), "payment_deadline": reminder["payment_deadline"]},
data={
"fee": money_text(reminder_items_total(reminder)),
"payment_deadline": reminder["payment_deadline"],
},
)
return reminder
@@ -2410,6 +2426,87 @@ class MemberRepository:
config.setdefault("member_number_sequences", {})
write_json_atomic(self.root / "repository.json", config)
def get_reminder_policy(self) -> dict:
config = self.get_configuration()
policy = config.get("reminder_policy") or {}
levels = sorted(
(
{
"level": int(entry.get("level", 0)),
"name": str(entry.get("name", "")),
"fee": money_text(entry.get("fee", "0")),
"payment_deadline_days": int(entry.get("payment_deadline_days", 14)),
}
for entry in policy.get("levels") or []
),
key=lambda entry: entry["level"],
)
standard_fee_items = [
{
"description": str(entry.get("description", "")),
"default_amount": money_text(entry.get("default_amount", "0")),
}
for entry in policy.get("standard_fee_items") or []
]
return {
"grace_days_after_due": int(policy.get("grace_days_after_due", 7)),
"levels": levels,
"standard_fee_items": standard_fee_items,
}
def save_reminder_policy(
self,
*,
grace_days_after_due: int,
levels: list[dict[str, str]],
standard_fee_items: list[dict[str, str]],
) -> None:
if grace_days_after_due < 0 or grace_days_after_due > 365:
raise RepositoryError("Die Kulanzfrist muss zwischen 0 und 365 Tagen liegen.")
normalized_levels = []
for index, entry in enumerate(levels, start=1):
name = str(entry.get("name", "")).strip()
if not name:
raise RepositoryError(f"Mahnstufe {index} benötigt einen Namen.")
try:
fee = decimal_value(entry.get("fee", "0"), "Gebühr")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if fee < 0:
raise RepositoryError("Die Gebühr darf nicht negativ sein.")
try:
deadline_days = int(entry.get("payment_deadline_days", 14))
except (TypeError, ValueError) as exc:
raise RepositoryError("Die Zahlungsfrist muss eine ganze Zahl sein.") from exc
if deadline_days < 1 or deadline_days > 365:
raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
# Levels are always renumbered 1..N by row order -- picking arbitrary level
# numbers would break the sequencing checks in create_reminder_draft.
normalized_levels.append(
{"level": index, "name": name, "fee": money_text(fee), "payment_deadline_days": deadline_days}
)
if not normalized_levels:
raise RepositoryError("Mindestens eine Mahnstufe ist erforderlich.")
normalized_items = []
for entry in standard_fee_items:
description = str(entry.get("description", "")).strip()
if not description:
raise RepositoryError("Jede Standardposition benötigt eine Beschreibung.")
try:
amount = decimal_value(entry.get("default_amount", "0"), "Standardbetrag")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if amount < 0:
raise RepositoryError("Der Standardbetrag darf nicht negativ sein.")
normalized_items.append({"description": description, "default_amount": money_text(amount)})
config = self.get_configuration()
config["reminder_policy"] = {
"grace_days_after_due": grace_days_after_due,
"levels": normalized_levels,
"standard_fee_items": normalized_items,
}
write_json_atomic(self.root / "repository.json", config)
def save_organization(self, values: dict[str, str]) -> None:
organization = {key: str(value).strip() for key, value in values.items()}
organization["iban"] = normalize_iban(organization.get("iban", ""))
@@ -2644,6 +2741,19 @@ def _contribution_override_summary(override: dict) -> str:
return f"{value_text} ({period})"
def _normalize_reminder_items(items: list[dict[str, str]] | None) -> list[dict[str, str]]:
normalized = []
for entry in items or []:
description = str(entry.get("description", "")).strip()
if not description:
raise ValueError("Jede Position benötigt eine Beschreibung.")
amount = decimal_value(entry.get("amount", "0"), "Betrag")
if amount < 0:
raise ValueError("Der Betrag darf nicht negativ sein.")
normalized.append({"description": description, "amount": money_text(amount)})
return normalized
def _dunning_hold_is_active(claim: dict) -> bool:
hold = claim.get("dunning_hold") or {}
if not hold.get("active"):