mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +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
@@ -153,6 +153,13 @@ def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
|
||||
return "open"
|
||||
|
||||
|
||||
def reminder_items_total(reminder: dict[str, Any]) -> Decimal:
|
||||
return sum(
|
||||
(decimal_value(item.get("amount", "0")) for item in reminder.get("items") or []),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
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 "")
|
||||
|
||||
@@ -21,6 +21,7 @@ from ccma.domain.contributions import (
|
||||
claim_status,
|
||||
claim_total,
|
||||
money_text,
|
||||
reminder_items_total,
|
||||
)
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member
|
||||
@@ -293,6 +294,10 @@ def _template_values(
|
||||
channel_labels = {"email": "E-Mail", "letter": "Brief", "personal": "Persönlich"}
|
||||
status = str(reminder.get("status", "draft"))
|
||||
channel = str(reminder.get("channel", ""))
|
||||
reminder_items = reminder.get("items") or []
|
||||
item_lines = [
|
||||
f"{item.get('description', '')}: {item.get('amount', '0.00')} EUR" for item in reminder_items
|
||||
]
|
||||
values.update(
|
||||
{
|
||||
"reminder.id": str(reminder.get("reminder_id", "")),
|
||||
@@ -305,11 +310,19 @@ def _template_values(
|
||||
str(reminder.get("payment_deadline") or "")
|
||||
),
|
||||
"reminder.payment_deadline_days": str(reminder.get("payment_deadline_days", "")),
|
||||
"reminder.fee": f"{reminder.get('fee', '0.00')} EUR",
|
||||
"reminder.fee": f"{money_text(reminder_items_total(reminder))} EUR",
|
||||
"reminder.items": "; ".join(item_lines),
|
||||
"reminder.detail": str(reminder.get("detail", "")),
|
||||
"reminder.channel": channel_labels.get(channel, channel),
|
||||
}
|
||||
)
|
||||
repeats["reminder.items"] = [
|
||||
{
|
||||
"item.description": str(item.get("description", "")),
|
||||
"item.amount": str(item.get("amount", "")),
|
||||
}
|
||||
for item in reminder_items
|
||||
]
|
||||
return values, repeats
|
||||
|
||||
|
||||
|
||||
@@ -69,6 +69,15 @@ def reminder_mail_bytes(
|
||||
bic = str(organization.get("bic", "")).strip()
|
||||
payment_details = f"IBAN: {iban}" + (f"\nBIC: {bic}" if bic else "")
|
||||
detail = str(reminder.get("detail", "")).strip()
|
||||
fee_items = reminder.get("items") or []
|
||||
fee_lines = (
|
||||
"\n".join(
|
||||
f"{item.get('description', 'Gebühr')}: {item.get('amount', '0.00')} Euro"
|
||||
for item in fee_items
|
||||
)
|
||||
if fee_items
|
||||
else "Mahngebühr: 0.00 Euro"
|
||||
)
|
||||
message.set_content(
|
||||
"\n".join(
|
||||
(
|
||||
@@ -82,7 +91,7 @@ def reminder_mail_bytes(
|
||||
f"{format_date_for_display(str(claim.get('due_date', '')))}",
|
||||
f"Neue Zahlungsfrist: "
|
||||
f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}",
|
||||
f"Mahngebühr: {reminder.get('fee', '0.00')} Euro",
|
||||
fee_lines,
|
||||
f"Hinweis: {detail}" if detail else "",
|
||||
"",
|
||||
"Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:",
|
||||
|
||||
+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"):
|
||||
|
||||
+168
-37
@@ -19,6 +19,7 @@ from ccma.domain.contributions import (
|
||||
decimal_value,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
reminder_items_total,
|
||||
)
|
||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||
@@ -348,7 +349,7 @@ class ClaimTab(ttk.Frame):
|
||||
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
|
||||
"",
|
||||
"",
|
||||
f"{reminder.get('fee', '0.00')} EUR",
|
||||
f"{money_text(reminder_items_total(reminder))} EUR",
|
||||
display_label(REMINDER_STATUS_LABELS, status),
|
||||
" · ".join(
|
||||
part
|
||||
@@ -867,65 +868,195 @@ class AllocateCreditDialog(_Dialog):
|
||||
|
||||
|
||||
class ReminderDialog(_Dialog):
|
||||
"""Drafts the next reminder in the sequence for a claim. The level is computed
|
||||
automatically (first not-yet-used level), and a "Vorlage" picks sensible defaults
|
||||
for name/deadline/fee items -- the matching dunning-policy level if one exists,
|
||||
a "Rücklastschrift" preset for a bounced direct debit (which occupies the same
|
||||
level slot as a regular reminder, since it's still the member's own failure to
|
||||
provide cover/valid bank details), or a blank template for manual entry. The fee
|
||||
items table itself is always free to edit no matter which preset was picked."""
|
||||
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnung vorbereiten", on_saved)
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
policy = repository.get_configuration().get("reminder_policy") or {}
|
||||
levels = policy.get("levels") or [
|
||||
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}
|
||||
]
|
||||
self.definition_by_label = {
|
||||
f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels
|
||||
data, _claim = repository.get_claim(member_id, claim_id)
|
||||
existing_levels = {
|
||||
int(item.get("level", 0))
|
||||
for item in data.reminders
|
||||
if str(item.get("claim_id", "")) == claim_id
|
||||
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
|
||||
}
|
||||
self.level_var = tk.StringVar(value=next(iter(self.definition_by_label)))
|
||||
self.detail_var = tk.StringVar()
|
||||
self.fee_var = tk.StringVar()
|
||||
self.deadline_var = tk.StringVar()
|
||||
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
|
||||
self.level = max(existing_levels, default=0) + 1
|
||||
self.policy = repository.get_reminder_policy()
|
||||
self.items: list[dict[str, str]] = []
|
||||
|
||||
self.frame.columnconfigure(1, weight=1)
|
||||
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
level_combo = ttk.Combobox(
|
||||
ttk.Label(self.frame, text=str(self.level), style="Mono.TLabel").grid(
|
||||
row=0, column=1, sticky="w", pady=5
|
||||
)
|
||||
|
||||
self.preset_by_label = self._build_presets()
|
||||
self.preset_var = tk.StringVar(value=next(iter(self.preset_by_label)))
|
||||
ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
preset_combo = ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.level_var,
|
||||
values=list(self.definition_by_label),
|
||||
textvariable=self.preset_var,
|
||||
values=list(self.preset_by_label),
|
||||
state="readonly",
|
||||
width=38,
|
||||
)
|
||||
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
|
||||
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
|
||||
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
|
||||
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
|
||||
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
preset_combo.grid(row=1, column=1, sticky="ew", pady=5)
|
||||
preset_combo.bind("<<ComboboxSelected>>", lambda _event: self._apply_preset())
|
||||
|
||||
self.name_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Bezeichnung").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.name_var, width=45).grid(
|
||||
row=2, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
|
||||
self.detail_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Details").grid(row=3, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(
|
||||
row=3, column=1, sticky="ew", pady=5
|
||||
)
|
||||
|
||||
self.deadline_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
|
||||
row=4, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=4, column=1, sticky="ew", pady=5)
|
||||
|
||||
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
|
||||
ttk.Label(self.frame, text="Versandweg").grid(row=5, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.channel_var,
|
||||
values=list(REMINDER_CHANNEL_LABELS.values()),
|
||||
state="readonly",
|
||||
).grid(row=4, column=1, sticky="ew", pady=5)
|
||||
self._load_definition()
|
||||
self._buttons(5, self._save)
|
||||
).grid(row=5, column=1, sticky="ew", pady=5)
|
||||
|
||||
def _load_definition(self) -> None:
|
||||
definition = self.definition_by_label[self.level_var.get()]
|
||||
self.fee_var.set(str(definition.get("fee", "0.00")))
|
||||
self.deadline_var.set(str(definition.get("payment_deadline_days", 14)))
|
||||
self._build_items_table(row=6)
|
||||
self._apply_preset()
|
||||
self._buttons(7, self._save)
|
||||
|
||||
def _build_presets(self) -> dict[str, dict]:
|
||||
presets: dict[str, dict] = {}
|
||||
matching = next((lvl for lvl in self.policy["levels"] if lvl["level"] == self.level), None)
|
||||
if matching:
|
||||
fee = decimal_value(matching["fee"])
|
||||
presets[f"Stufe {self.level}: {matching['name']}"] = {
|
||||
"name": matching["name"],
|
||||
"payment_deadline_days": matching["payment_deadline_days"],
|
||||
"items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [],
|
||||
}
|
||||
failed_debit_amount = self._default_amount_for("Rücklastschrift")
|
||||
presets["Rücklastschrift"] = {
|
||||
"name": "Rücklastschrift",
|
||||
"payment_deadline_days": 14,
|
||||
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
|
||||
}
|
||||
presets["Frei (eigene Eingabe)"] = {"name": "", "payment_deadline_days": 14, "items": []}
|
||||
return presets
|
||||
|
||||
def _default_amount_for(self, needle: str) -> str:
|
||||
for entry in self.policy["standard_fee_items"]:
|
||||
if needle.casefold() in str(entry.get("description", "")).casefold():
|
||||
return str(entry.get("default_amount", "0.00"))
|
||||
return "0.00"
|
||||
|
||||
def _apply_preset(self) -> None:
|
||||
preset = self.preset_by_label[self.preset_var.get()]
|
||||
self.name_var.set(preset["name"])
|
||||
self.deadline_var.set(str(preset["payment_deadline_days"]))
|
||||
self.items = [dict(item) for item in preset["items"]]
|
||||
self._refresh_items()
|
||||
|
||||
def _build_items_table(self, row: int) -> None:
|
||||
table_frame = ttk.LabelFrame(self.frame, text="Positionen", padding=8)
|
||||
table_frame.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=(8, 0))
|
||||
table_frame.columnconfigure(0, weight=1)
|
||||
self.items_tree = ttk.Treeview(
|
||||
table_frame, columns=("description", "amount"), show="headings", height=4, selectmode="browse"
|
||||
)
|
||||
self.items_tree.heading("description", text="Beschreibung")
|
||||
self.items_tree.heading("amount", text="Betrag")
|
||||
self.items_tree.column("description", width=220, anchor="w")
|
||||
self.items_tree.column("amount", width=100, anchor="w", stretch=False)
|
||||
self.items_tree.grid(row=0, column=0, sticky="ew")
|
||||
|
||||
controls = ttk.Frame(table_frame)
|
||||
controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
self.item_description_var = tk.StringVar()
|
||||
description_combo = ttk.Combobox(
|
||||
controls,
|
||||
textvariable=self.item_description_var,
|
||||
values=[str(entry.get("description", "")) for entry in self.policy["standard_fee_items"]],
|
||||
width=22,
|
||||
)
|
||||
description_combo.pack(side="left", padx=(0, 8))
|
||||
description_combo.bind("<<ComboboxSelected>>", lambda _event: self._prefill_item_amount())
|
||||
self.item_amount_var = tk.StringVar()
|
||||
ttk.Entry(controls, textvariable=self.item_amount_var, width=10).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(controls, text="Position hinzufügen", command=self._add_item).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(controls, text="Position entfernen", command=self._remove_item).pack(side="left")
|
||||
|
||||
def _prefill_item_amount(self) -> None:
|
||||
description = self.item_description_var.get()
|
||||
for entry in self.policy["standard_fee_items"]:
|
||||
if str(entry.get("description", "")) == description:
|
||||
self.item_amount_var.set(str(entry.get("default_amount", "0.00")))
|
||||
return
|
||||
|
||||
def _refresh_items(self) -> None:
|
||||
self.items_tree.delete(*self.items_tree.get_children())
|
||||
for index, item in enumerate(self.items):
|
||||
self.items_tree.insert(
|
||||
"", "end", iid=str(index), values=(item["description"], f"{item['amount']} EUR")
|
||||
)
|
||||
|
||||
def _add_item(self) -> None:
|
||||
description = self.item_description_var.get().strip()
|
||||
if not description:
|
||||
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.item_amount_var.get(), "Betrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.items.append({"description": description, "amount": money_text(amount)})
|
||||
self.item_description_var.set("")
|
||||
self.item_amount_var.set("")
|
||||
self._refresh_items()
|
||||
|
||||
def _remove_item(self) -> None:
|
||||
selected = self.items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
del self.items[int(selected[0])]
|
||||
self._refresh_items()
|
||||
|
||||
def _save(self):
|
||||
definition = self.definition_by_label[self.level_var.get()]
|
||||
try:
|
||||
deadline_days = int(self.deadline_var.get())
|
||||
except ValueError:
|
||||
messagebox.showerror(
|
||||
"Ungültige Frist", "Die Zahlungsfrist muss eine ganze Zahl sein.", parent=self
|
||||
)
|
||||
return
|
||||
try:
|
||||
self.repository.create_reminder_draft(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
level=int(definition.get("level", 1)),
|
||||
name=str(definition.get("name", "Mahnung")),
|
||||
payment_deadline_days=int(self.deadline_var.get()),
|
||||
level=self.level,
|
||||
name=self.name_var.get(),
|
||||
payment_deadline_days=deadline_days,
|
||||
detail=self.detail_var.get(),
|
||||
fee=self.fee_var.get(),
|
||||
items=self.items,
|
||||
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||
)
|
||||
except (ValueError, RepositoryError) as exc:
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.config import AppConfig
|
||||
from ccma.domain.contributions import decimal_value, money_text
|
||||
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||
from ccma.services.intervals import (
|
||||
IntervalValidationError,
|
||||
@@ -84,18 +85,21 @@ class OptionsDialog(tk.Toplevel):
|
||||
member_numbers = ttk.Frame(notebook, padding=16)
|
||||
organization = ttk.Frame(notebook, padding=16)
|
||||
automation = ttk.Frame(notebook, padding=16)
|
||||
reminders = ttk.Frame(notebook, padding=16)
|
||||
changelog = ChangelogView(notebook)
|
||||
notebook.add(paths, text="Pfade")
|
||||
notebook.add(appearance, text="Darstellung")
|
||||
notebook.add(member_numbers, text="Mitgliedsnummern")
|
||||
notebook.add(organization, text="Verein / Absender")
|
||||
notebook.add(automation, text="Hausmeister")
|
||||
notebook.add(reminders, text="Mahnungen")
|
||||
notebook.add(changelog, text="Changelog")
|
||||
self._build_paths(paths)
|
||||
self._build_appearance(appearance)
|
||||
self._build_member_numbers(member_numbers)
|
||||
self._build_organization(organization)
|
||||
self._build_automation(automation)
|
||||
self._build_reminders(reminders)
|
||||
|
||||
buttons = ttk.Frame(root)
|
||||
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
|
||||
@@ -336,6 +340,234 @@ class OptionsDialog(tk.Toplevel):
|
||||
preview = f"UNGÜLTIG: {exc}"
|
||||
self.number_preview_var.set(preview)
|
||||
|
||||
def _build_reminders(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
policy = self.repository.get_reminder_policy()
|
||||
self.grace_days_var = tk.StringVar(value=str(policy["grace_days_after_due"]))
|
||||
ttk.Label(parent, text="Kulanzfrist nach Fälligkeit (Tage)").grid(
|
||||
row=0, column=0, sticky="w", pady=6
|
||||
)
|
||||
ttk.Spinbox(parent, from_=0, to=365, textvariable=self.grace_days_var, width=6).grid(
|
||||
row=0, column=1, sticky="w", pady=6
|
||||
)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text="Ab wann nach Fälligkeit die erste Mahnstufe fällig wird.",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
|
||||
|
||||
levels_frame = ttk.LabelFrame(parent, text="Mahnstufen", padding=10)
|
||||
levels_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(4, 0))
|
||||
levels_frame.columnconfigure(0, weight=1)
|
||||
self.levels = [dict(level) for level in policy["levels"]]
|
||||
self.levels_tree = ttk.Treeview(
|
||||
levels_frame, columns=("level", "name", "fee", "deadline"), show="headings", height=5
|
||||
)
|
||||
for key, title, width in (
|
||||
("level", "Stufe", 55),
|
||||
("name", "Name", 220),
|
||||
("fee", "Gebühr", 90),
|
||||
("deadline", "Frist (Tage)", 100),
|
||||
):
|
||||
self.levels_tree.heading(key, text=title)
|
||||
self.levels_tree.column(key, width=width, anchor="w")
|
||||
self.levels_tree.grid(row=0, column=0, sticky="ew")
|
||||
self.levels_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_level())
|
||||
|
||||
level_controls = ttk.Frame(levels_frame)
|
||||
level_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
self.level_name_var = tk.StringVar()
|
||||
self.level_fee_var = tk.StringVar()
|
||||
self.level_deadline_var = tk.StringVar()
|
||||
ttk.Entry(level_controls, textvariable=self.level_name_var, width=22).pack(side="left", padx=(0, 6))
|
||||
ttk.Entry(level_controls, textvariable=self.level_fee_var, width=8).pack(side="left", padx=(0, 6))
|
||||
ttk.Entry(level_controls, textvariable=self.level_deadline_var, width=8).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(level_controls, text="Übernehmen", command=self._apply_level).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(level_controls, text="Hinzufügen", command=self._add_level).pack(side="left", padx=(0, 6))
|
||||
ttk.Button(level_controls, text="Entfernen", command=self._remove_level).pack(side="left")
|
||||
ttk.Label(
|
||||
parent,
|
||||
text="Stufen werden immer nach ihrer Reihenfolge in der Liste 1, 2, 3, … nummeriert.",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
||||
|
||||
items_frame = ttk.LabelFrame(parent, text="Standardpositionen (Beschreibung & Betrag)", padding=10)
|
||||
items_frame.grid(row=4, column=0, columnspan=2, sticky="nsew", pady=(16, 0))
|
||||
items_frame.columnconfigure(0, weight=1)
|
||||
self.standard_items = [dict(item) for item in policy["standard_fee_items"]]
|
||||
self.standard_items_tree = ttk.Treeview(
|
||||
items_frame, columns=("description", "amount"), show="headings", height=5
|
||||
)
|
||||
self.standard_items_tree.heading("description", text="Beschreibung")
|
||||
self.standard_items_tree.heading("amount", text="Standardbetrag")
|
||||
self.standard_items_tree.column("description", width=260, anchor="w")
|
||||
self.standard_items_tree.column("amount", width=120, anchor="w")
|
||||
self.standard_items_tree.grid(row=0, column=0, sticky="ew")
|
||||
self.standard_items_tree.bind(
|
||||
"<<TreeviewSelect>>", lambda _event: self._load_selected_standard_item()
|
||||
)
|
||||
|
||||
item_controls = ttk.Frame(items_frame)
|
||||
item_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
self.standard_item_description_var = tk.StringVar()
|
||||
self.standard_item_amount_var = tk.StringVar()
|
||||
ttk.Entry(item_controls, textvariable=self.standard_item_description_var, width=26).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Entry(item_controls, textvariable=self.standard_item_amount_var, width=10).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Übernehmen", command=self._apply_standard_item).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Hinzufügen", command=self._add_standard_item).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Entfernen", command=self._remove_standard_item).pack(side="left")
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Diese Vorlagen stehen bei Mahnungen und Rücklastschriften als Positionen zur "
|
||||
"Auswahl, z. B. Mahngebühr, Rücklastschriftgebühr, Bankgebühr oder Porto."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=560,
|
||||
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
||||
|
||||
self._refresh_levels()
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _refresh_levels(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
self.levels_tree.delete(*self.levels_tree.get_children())
|
||||
for index, level in enumerate(self.levels):
|
||||
self.levels_tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(
|
||||
index + 1,
|
||||
level.get("name", ""),
|
||||
f"{level.get('fee', '0.00')} EUR",
|
||||
level.get("payment_deadline_days", 14),
|
||||
),
|
||||
)
|
||||
if selected and self.levels_tree.exists(selected[0]):
|
||||
self.levels_tree.selection_set(selected[0])
|
||||
|
||||
def _load_selected_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
level = self.levels[int(selected[0])]
|
||||
self.level_name_var.set(level.get("name", ""))
|
||||
self.level_fee_var.set(str(level.get("fee", "0.00")))
|
||||
self.level_deadline_var.set(str(level.get("payment_deadline_days", 14)))
|
||||
|
||||
def _apply_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
|
||||
return
|
||||
name = self.level_name_var.get().strip()
|
||||
if not name:
|
||||
messagebox.showerror("Name fehlt", "Bitte einen Namen angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
fee = decimal_value(self.level_fee_var.get(), "Gebühr")
|
||||
deadline = int(self.level_deadline_var.get())
|
||||
except (ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
|
||||
return
|
||||
self.levels[int(selected[0])] = {
|
||||
"name": name,
|
||||
"fee": money_text(fee),
|
||||
"payment_deadline_days": deadline,
|
||||
}
|
||||
self._refresh_levels()
|
||||
|
||||
def _add_level(self) -> None:
|
||||
name = self.level_name_var.get().strip() or "Neue Mahnstufe"
|
||||
try:
|
||||
fee = decimal_value(self.level_fee_var.get() or "0", "Gebühr")
|
||||
deadline = int(self.level_deadline_var.get() or "14")
|
||||
except (ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
|
||||
return
|
||||
self.levels.append({"name": name, "fee": money_text(fee), "payment_deadline_days": deadline})
|
||||
self._refresh_levels()
|
||||
|
||||
def _remove_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
|
||||
return
|
||||
del self.levels[int(selected[0])]
|
||||
self._refresh_levels()
|
||||
|
||||
def _refresh_standard_items(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
self.standard_items_tree.delete(*self.standard_items_tree.get_children())
|
||||
for index, item in enumerate(self.standard_items):
|
||||
self.standard_items_tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(item.get("description", ""), f"{item.get('default_amount', '0.00')} EUR"),
|
||||
)
|
||||
if selected and self.standard_items_tree.exists(selected[0]):
|
||||
self.standard_items_tree.selection_set(selected[0])
|
||||
|
||||
def _load_selected_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
item = self.standard_items[int(selected[0])]
|
||||
self.standard_item_description_var.set(item.get("description", ""))
|
||||
self.standard_item_amount_var.set(str(item.get("default_amount", "0.00")))
|
||||
|
||||
def _apply_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
description = self.standard_item_description_var.get().strip()
|
||||
if not description:
|
||||
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.standard_item_amount_var.get(), "Standardbetrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.standard_items[int(selected[0])] = {
|
||||
"description": description,
|
||||
"default_amount": money_text(amount),
|
||||
}
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _add_standard_item(self) -> None:
|
||||
description = self.standard_item_description_var.get().strip() or "Neue Position"
|
||||
try:
|
||||
amount = decimal_value(self.standard_item_amount_var.get() or "0", "Standardbetrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.standard_items.append({"description": description, "default_amount": money_text(amount)})
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _remove_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
del self.standard_items[int(selected[0])]
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _choose_store(self) -> None:
|
||||
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
|
||||
initial = current if current.is_dir() else current.parent
|
||||
@@ -377,6 +609,7 @@ class OptionsDialog(tk.Toplevel):
|
||||
anniversary_before = _parse_day_window(self.anniversary_before_var.get(), "Jubiläen vorher")
|
||||
anniversary_after = _parse_day_window(self.anniversary_after_var.get(), "Jubiläen nachher")
|
||||
anniversary_intervals = normalize_anniversary_intervals(self.anniversary_intervals_var.get())
|
||||
grace_days_after_due = _parse_day_window(self.grace_days_var.get(), "Kulanzfrist")
|
||||
except (ValueError, IntervalValidationError) as exc:
|
||||
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
|
||||
return
|
||||
@@ -439,6 +672,11 @@ class OptionsDialog(tk.Toplevel):
|
||||
self.repository.save_organization(
|
||||
{key: variable.get() for key, variable in self.organization_vars.items()}
|
||||
)
|
||||
self.repository.save_reminder_policy(
|
||||
grace_days_after_due=grace_days_after_due,
|
||||
levels=self.levels,
|
||||
standard_fee_items=self.standard_items,
|
||||
)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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=[],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user