From a1719cad5e892449465f968df40565e6672709c1 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 02:19:34 +0200 Subject: [PATCH] Handle failed direct debits through the same reminder mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ccma/domain/contributions.py | 7 + src/ccma/services/documents.py | 15 +- src/ccma/services/reminder_mail.py | 11 +- src/ccma/storage/repository.py | 162 ++++++++++++++++---- src/ccma/ui/claim_tab.py | 205 ++++++++++++++++++++----- src/ccma/ui/options_dialog.py | 238 +++++++++++++++++++++++++++++ tests/test_contributions.py | 6 +- tests/test_reminder_mail.py | 2 +- tests/test_reminders.py | 109 ++++++++++++- 9 files changed, 684 insertions(+), 71 deletions(-) diff --git a/src/ccma/domain/contributions.py b/src/ccma/domain/contributions.py index 6d0b581..af139d1 100644 --- a/src/ccma/domain/contributions.py +++ b/src/ccma/domain/contributions.py @@ -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 "") diff --git a/src/ccma/services/documents.py b/src/ccma/services/documents.py index bb760c4..42e2b87 100644 --- a/src/ccma/services/documents.py +++ b/src/ccma/services/documents.py @@ -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 diff --git a/src/ccma/services/reminder_mail.py b/src/ccma/services/reminder_mail.py index a9accbd..616da47 100644 --- a/src/ccma/services/reminder_mail.py +++ b/src/ccma/services/reminder_mail.py @@ -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:", diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index d412d80..924f8a3 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -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"): diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 4ef00e7..a24777b 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -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("<>", 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("<>", 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("<>", 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: diff --git a/src/ccma/ui/options_dialog.py b/src/ccma/ui/options_dialog.py index 1d6c640..dac9788 100644 --- a/src/ccma/ui/options_dialog.py +++ b/src/ccma/ui/options_dialog.py @@ -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("<>", 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( + "<>", 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 diff --git a/tests/test_contributions.py b/tests/test_contributions.py index 7113d6e..2a307be 100644 --- a/tests/test_contributions.py +++ b/tests/test_contributions.py @@ -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" diff --git a/tests/test_reminder_mail.py b/tests/test_reminder_mail.py index 2e3aed2..f385572 100644 --- a/tests/test_reminder_mail.py +++ b/tests/test_reminder_mail.py @@ -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( diff --git a/tests/test_reminders.py b/tests/test_reminders.py index f1e5091..04130bd 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -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=[], + )