mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
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:
co-authored by
Claude Sonnet 5
parent
46795fc110
commit
a1719cad5e
+136
-26
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user