From a1719cad5e892449465f968df40565e6672709c1 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 02:19:34 +0200 Subject: [PATCH 1/6] 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=[], + ) From d4dfd0066a6b0e24662d498e8ee67de8dd42ad75 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 02:27:00 +0200 Subject: [PATCH 2/6] Fix reminder dialog sizing, item editing, and old-repo fee defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog's base class sets resizable(False, False) before this subclass's content (including the preset-populated items table) exists, so its initial size stayed locked to a too-small guess and cut off the bottom. It now explicitly sizes to its actual content after everything, including the selected preset's items, has been built. The items table only supported add/remove -- there was no way to change an already-added row's amount (e.g. after picking the Rücklastschrift preset, its prefilled fee couldn't be adjusted). Selecting a row now loads it into the description/amount fields, and a new "Aktualisieren" button applies edits back to that row. The Rücklastschrift preset label was missing the "Stufe N:" prefix the other presets have, inconsistent for no reason. Also fixed a real gap: repositories created before standard_fee_items existed had no such key in repository.json at all, so Optionen showed an empty Standardpositionen table instead of the built-in defaults. get_reminder_policy() now backfills the defaults when the key is missing entirely, while still respecting a list the board intentionally emptied and saved. Co-Authored-By: Claude Sonnet 5 --- src/ccma/storage/repository.py | 9 +++++++- src/ccma/ui/claim_tab.py | 41 +++++++++++++++++++++++++++++++++- tests/test_reminders.py | 23 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 924f8a3..c78b0a2 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -2441,12 +2441,19 @@ class MemberRepository: ), key=lambda entry: entry["level"], ) + # Repositories created before this setting existed have no "standard_fee_items" + # key at all -- fall back to the built-in defaults rather than an empty list, but + # once the board has actually saved (and possibly emptied) the list, respect that. + if "standard_fee_items" in policy: + standard_items_raw = policy.get("standard_fee_items") or [] + else: + standard_items_raw = DEFAULT_CONFIGURATION["reminder_policy"]["standard_fee_items"] 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 [] + for entry in standard_items_raw ] return { "grace_days_after_due": int(policy.get("grace_days_after_due", 7)), diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index a24777b..0ef4af7 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -939,6 +939,14 @@ class ReminderDialog(_Dialog): self._build_items_table(row=6) self._apply_preset() self._buttons(7, self._save) + # Size to fit the fully built content (incl. the preset-populated items table) + # instead of whatever size Tk guessed before everything was in place -- the + # base dialog sets resizable(False, False) before this subclass's content + # exists, so an explicit geometry() is needed or the window stays clipped. + self.update_idletasks() + width, height = self.winfo_reqwidth(), self.winfo_reqheight() + self.geometry(f"{width}x{height}") + self.minsize(width, height) def _build_presets(self) -> dict[str, dict]: presets: dict[str, dict] = {} @@ -951,7 +959,7 @@ class ReminderDialog(_Dialog): "items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [], } failed_debit_amount = self._default_amount_for("Rücklastschrift") - presets["Rücklastschrift"] = { + presets[f"Stufe {self.level}: Rücklastschrift"] = { "name": "Rücklastschrift", "payment_deadline_days": 14, "items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}], @@ -984,6 +992,7 @@ class ReminderDialog(_Dialog): 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") + self.items_tree.bind("<>", lambda _event: self._load_selected_item()) controls = ttk.Frame(table_frame) controls.grid(row=1, column=0, sticky="ew", pady=(8, 0)) @@ -1001,6 +1010,7 @@ class ReminderDialog(_Dialog): ttk.Button(controls, text="Position hinzufügen", command=self._add_item).pack( side="left", padx=(0, 8) ) + ttk.Button(controls, text="Aktualisieren", command=self._update_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: @@ -1017,6 +1027,14 @@ class ReminderDialog(_Dialog): "", "end", iid=str(index), values=(item["description"], f"{item['amount']} EUR") ) + def _load_selected_item(self) -> None: + selected = self.items_tree.selection() + if not selected: + return + item = self.items[int(selected[0])] + self.item_description_var.set(item["description"]) + self.item_amount_var.set(item["amount"]) + def _add_item(self) -> None: description = self.item_description_var.get().strip() if not description: @@ -1032,12 +1050,33 @@ class ReminderDialog(_Dialog): self.item_amount_var.set("") self._refresh_items() + def _update_item(self) -> None: + selected = self.items_tree.selection() + if not selected: + messagebox.showerror( + "Auswahl fehlt", "Bitte die zu ändernde Position auswählen.", parent=self + ) + return + 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[int(selected[0])] = {"description": description, "amount": money_text(amount)} + 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.item_description_var.set("") + self.item_amount_var.set("") self._refresh_items() def _save(self): diff --git a/tests/test_reminders.py b/tests/test_reminders.py index 04130bd..ecca884 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -1,3 +1,4 @@ +import json from datetime import date, timedelta from decimal import Decimal @@ -278,3 +279,25 @@ def test_reminder_policy_rejects_invalid_input(tmp_path) -> None: levels=[{"name": "Mahnung", "fee": "-1.00", "payment_deadline_days": 14}], standard_fee_items=[], ) + + +def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + config_path = tmp_path / "repository.json" + raw = json.loads(config_path.read_text(encoding="utf-8")) + # Simulate a repository created before "standard_fee_items" existed at all. + del raw["reminder_policy"]["standard_fee_items"] + config_path.write_text(json.dumps(raw), encoding="utf-8") + + policy = repository.get_reminder_policy() + assert policy["standard_fee_items"], "should fall back to built-in defaults" + assert any(item["description"] == "Rücklastschriftgebühr" for item in policy["standard_fee_items"]) + + # But once the board explicitly saves an empty list, that choice is respected. + repository.save_reminder_policy( + grace_days_after_due=policy["grace_days_after_due"], + levels=policy["levels"], + standard_fee_items=[], + ) + assert repository.get_reminder_policy()["standard_fee_items"] == [] From 3940926a22baf9fc9032b8933a57d94d506b7c61 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 02:28:51 +0200 Subject: [PATCH 3/6] Line up the reminder items edit row with the table columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beschreibung/Betrag now sit on their own row directly under the table, sized to the same 220:100 ratio as the table's own columns, with the Hinzufügen/Aktualisieren/Entfernen buttons moved to a row below that instead of everything being crammed into one row. Co-Authored-By: Claude Sonnet 5 --- src/ccma/ui/claim_tab.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 0ef4af7..fc875fb 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -994,24 +994,33 @@ class ReminderDialog(_Dialog): self.items_tree.grid(row=0, column=0, sticky="ew") self.items_tree.bind("<>", lambda _event: self._load_selected_item()) - controls = ttk.Frame(table_frame) - controls.grid(row=1, column=0, sticky="ew", pady=(8, 0)) + # Edit row lines up under the table's own columns (same 220:100 width ratio as + # "Beschreibung"/"Betrag" above) instead of the fields and buttons being crammed + # into one row. + edit_row = ttk.Frame(table_frame) + edit_row.grid(row=1, column=0, sticky="ew", pady=(8, 0)) + edit_row.columnconfigure(0, weight=22) + edit_row.columnconfigure(1, weight=10) self.item_description_var = tk.StringVar() description_combo = ttk.Combobox( - controls, + edit_row, 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.grid(row=0, column=0, sticky="ew", 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( + ttk.Entry(edit_row, textvariable=self.item_amount_var).grid(row=0, column=1, sticky="ew") + + buttons_row = ttk.Frame(table_frame) + buttons_row.grid(row=2, column=0, sticky="w", pady=(8, 0)) + ttk.Button(buttons_row, text="Position hinzufügen", command=self._add_item).pack( side="left", padx=(0, 8) ) - ttk.Button(controls, text="Aktualisieren", command=self._update_item).pack(side="left", padx=(0, 8)) - ttk.Button(controls, text="Position entfernen", command=self._remove_item).pack(side="left") + ttk.Button(buttons_row, text="Aktualisieren", command=self._update_item).pack( + side="left", padx=(0, 8) + ) + ttk.Button(buttons_row, text="Position entfernen", command=self._remove_item).pack(side="left") def _prefill_item_amount(self) -> None: description = self.item_description_var.get() From f3bdffd347c0282257e17313ced0b9164e4f4d40 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 02:38:56 +0200 Subject: [PATCH 4/6] Make ReminderDialog resizable/wider and fix Options dialog bottom clipping ReminderDialog: override the shared dialog base's resizable(False, False), widen the initial layout, and pin the items-table edit row's amount field to the exact same pixel width as the table's fixed "amount" column (via a pack_propagate(False) frame) while the description field stretches like the table's own column does -- keeps alignment exact even after resizing. OptionsDialog: compute the window's initial geometry from its actual required size after all tabs (incl. "Mahnungen") are built, instead of a static guess that clipped the Mahnungen tab's tables/buttons at the bottom. Co-Authored-By: Claude Sonnet 5 --- src/ccma/ui/claim_tab.py | 41 ++++++++++++++++++++++++----------- src/ccma/ui/options_dialog.py | 12 ++++++++-- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index fc875fb..99cd081 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -878,6 +878,10 @@ class ReminderDialog(_Dialog): def __init__(self, master, repository, member_id, claim_id, on_saved): super().__init__(master, "Mahnung vorbereiten", on_saved) + # Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table + # of freely-editable fee items whose content varies a lot -- make it resizable + # so a long/multi-item Mahnung doesn't get cramped. + self.resizable(True, True) self.repository, self.member_id, self.claim_id = repository, member_id, claim_id data, _claim = repository.get_claim(member_id, claim_id) existing_levels = { @@ -891,6 +895,7 @@ class ReminderDialog(_Dialog): self.items: list[dict[str, str]] = [] self.frame.columnconfigure(1, weight=1) + self.frame.rowconfigure(6, weight=1) ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12)) ttk.Label(self.frame, text=str(self.level), style="Mono.TLabel").grid( row=0, column=1, sticky="w", pady=5 @@ -904,20 +909,20 @@ class ReminderDialog(_Dialog): textvariable=self.preset_var, values=list(self.preset_by_label), state="readonly", - width=38, + width=55, ) 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( + ttk.Entry(self.frame, textvariable=self.name_var, width=64).grid( row=2, column=1, sticky="ew", pady=5 ) 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( + ttk.Entry(self.frame, textvariable=self.detail_var, width=64).grid( row=3, column=1, sticky="ew", pady=5 ) @@ -943,8 +948,11 @@ class ReminderDialog(_Dialog): # instead of whatever size Tk guessed before everything was in place -- the # base dialog sets resizable(False, False) before this subclass's content # exists, so an explicit geometry() is needed or the window stays clipped. + # The floor of 700 keeps the dialog comfortably wide even for a short name/ + # detail; the user can still grow it further since it's resizable now. self.update_idletasks() - width, height = self.winfo_reqwidth(), self.winfo_reqheight() + width = max(700, self.winfo_reqwidth()) + height = self.winfo_reqheight() self.geometry(f"{width}x{height}") self.minsize(width, height) @@ -984,23 +992,27 @@ class ReminderDialog(_Dialog): 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) + table_frame.rowconfigure(0, weight=1) + amount_column_width = 100 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") + self.items_tree.column("description", width=320, anchor="w") + self.items_tree.column("amount", width=amount_column_width, anchor="w", stretch=False) + self.items_tree.grid(row=0, column=0, sticky="nsew") self.items_tree.bind("<>", lambda _event: self._load_selected_item()) - # Edit row lines up under the table's own columns (same 220:100 width ratio as - # "Beschreibung"/"Betrag" above) instead of the fields and buttons being crammed - # into one row. + # Edit row lines up under the table's own columns: "Beschreibung" stretches to + # fill remaining space just like that Treeview column does, while "Betrag" sits + # in a frame pinned to the exact same pixel width as the fixed "amount" column + # above it (Entry's width= is in characters, not pixels, so a fixed-size frame + # is what gets a genuinely exact match instead of an approximate ratio). edit_row = ttk.Frame(table_frame) edit_row.grid(row=1, column=0, sticky="ew", pady=(8, 0)) - edit_row.columnconfigure(0, weight=22) - edit_row.columnconfigure(1, weight=10) + edit_row.columnconfigure(0, weight=1) + edit_row.columnconfigure(1, weight=0) self.item_description_var = tk.StringVar() description_combo = ttk.Combobox( edit_row, @@ -1009,8 +1021,11 @@ class ReminderDialog(_Dialog): ) description_combo.grid(row=0, column=0, sticky="ew", padx=(0, 8)) description_combo.bind("<>", lambda _event: self._prefill_item_amount()) + amount_frame = ttk.Frame(edit_row, width=amount_column_width) + amount_frame.grid(row=0, column=1, sticky="ns") + amount_frame.pack_propagate(False) self.item_amount_var = tk.StringVar() - ttk.Entry(edit_row, textvariable=self.item_amount_var).grid(row=0, column=1, sticky="ew") + ttk.Entry(amount_frame, textvariable=self.item_amount_var).pack(fill="both", expand=True) buttons_row = ttk.Frame(table_frame) buttons_row.grid(row=2, column=0, sticky="w", pady=(8, 0)) diff --git a/src/ccma/ui/options_dialog.py b/src/ccma/ui/options_dialog.py index dac9788..057790e 100644 --- a/src/ccma/ui/options_dialog.py +++ b/src/ccma/ui/options_dialog.py @@ -61,13 +61,21 @@ class OptionsDialog(tk.Toplevel): ) } self.title("Optionen") - self.geometry("900x650") - self.minsize(760, 540) self.transient(master.winfo_toplevel()) self.grab_set() self.resizable(True, True) self._build_ui() self.bind("", lambda _event: self.destroy()) + # A ttk.Notebook sizes itself to its tallest/widest tab, so once every tab + # (including "Mahnungen" with its two tables) is built, the natural required + # size reflects whichever one actually needs the most room -- open at that size + # instead of a static guess that clips a table/buttons at the bottom. The floor + # keeps the window from opening awkwardly small on short content. + self.update_idletasks() + width = max(900, self.winfo_reqwidth()) + height = max(650, self.winfo_reqheight()) + self.geometry(f"{width}x{height}") + self.minsize(760, 540) self.after_idle(self._center_on_parent) def _build_ui(self) -> None: From 186af530b08fcc75a16a0b4080fb8d3a39e6f94b Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 02:50:46 +0200 Subject: [PATCH 5/6] =?UTF-8?q?Allow=20editing=20reminders=20(incl.=20undo?= =?UTF-8?q?ing=20"sent")=20and=20prefill=20a=20R=C3=BCcklastschrift=20reas?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generated/sent Mahnung email couldn't be corrected before: sent reminders were locked, so a wording mistake meant living with it. Add edit_reminder_draft() for draft/generated reminders and revert_reminder_sent() to safely undo a "sent" reminder (removing the fee items it booked, refusing if a payment already covers them or a higher Mahnstufe already exists) so it can be edited and resent. ReminderDialog now supports an edit mode (reminder=... prefills name/detail/ items/deadline/channel, level stays fixed) and claim_tab gained a "Mahnung bearbeiten" button that reverts-then-edits for sent reminders automatically, asking for confirmation first since it un-books the fee. The Rücklastschrift preset now also prefills "Details" with a short explanation ("Leider konnte die Lastschrift ... nicht eingelöst werden.") since that field goes straight into the reminder email -- the board can append the concrete reason (mangels Deckung, Konto ungültig, ...) right there instead of the field starting empty. Co-Authored-By: Claude Sonnet 5 --- src/ccma/storage/repository.py | 93 ++++++++++++++++++++++ src/ccma/ui/claim_tab.py | 116 ++++++++++++++++++++++------ tests/test_reminders.py | 136 +++++++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 23 deletions(-) diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index c78b0a2..c61b98e 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -17,6 +17,8 @@ from ccma.domain.contributions import ( CONTRIBUTION_OVERRIDE_KIND_LABELS, allocated_total, claim_balance, + claim_items, + claim_settled_total, claim_total, contribution_override_ranges_overlap, credit_allocated_total, @@ -2108,6 +2110,97 @@ class MemberRepository: references={"claim_id": claim_id, "reminder_id": reminder_id}, ) + def edit_reminder_draft( + self, + member_id: str, + claim_id: str, + reminder_id: str, + *, + name: str, + payment_deadline_days: int, + detail: str = "", + items: list[dict[str, str]] | None = None, + channel: str = "email", + ) -> dict: + try: + normalized_items = _normalize_reminder_items(items) + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + 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) + reminder = self._find_reminder(data, claim_id, reminder_id) + if str(reminder.get("status", "draft")) not in {"draft", "generated"}: + raise RepositoryError("Nur ein Entwurf kann bearbeitet werden.") + reminder["name"] = name.strip() or reminder["name"] + reminder["detail"] = detail.strip() + reminder["channel"] = channel.strip() or "email" + reminder["payment_deadline_days"] = payment_deadline_days + reminder["items"] = normalized_items + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="reminder_draft_edited", + summary=f"Mahnungsentwurf bearbeitet: {reminder['name']}", + references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])}, + data={"fee": money_text(reminder_items_total(reminder))}, + ) + return reminder + + def revert_reminder_sent(self, member_id: str, claim_id: str, reminder_id: str) -> dict: + """Undoes mark_reminder_sent: removes the fee items it booked onto the claim + and puts the reminder back into "draft" so its text/items can be edited and it + can be sent again -- e.g. after generating a Mahnung email and noticing the + wording needs a fix. Refuses if a later Mahnstufe already exists (would break + the level ordering) or if payments already booked against the claim would no + longer fit once the fee is removed.""" + data, claim = self.get_claim(member_id, claim_id) + reminder = self._find_reminder(data, claim_id, reminder_id) + if str(reminder.get("status", "")) != "sent": + raise RepositoryError("Nur eine bereits versandte Mahnung kann zurückgesetzt werden.") + if any( + str(item.get("claim_id", "")) == claim_id + and int(item.get("level", 0)) > int(reminder.get("level", 0)) + and str(item.get("status", "draft")) in {"draft", "generated", "sent"} + for item in data.reminders + ): + raise RepositoryError( + "Es existiert bereits eine höhere Mahnstufe; diese Mahnung kann nicht " + "zurückgesetzt werden." + ) + fee_item_ids = set(reminder.get("fee_item_ids") or []) + if fee_item_ids: + removed_total = sum( + ( + decimal_value(item.get("amount", "0")) + for item in claim_items(claim) + if str(item.get("item_id", "")) in fee_item_ids + ), + Decimal("0"), + ) + new_total = claim_total(claim) - removed_total + if claim_settled_total(data, claim) > new_total: + raise RepositoryError( + "Für diese Forderung wurden bereits Zahlungen verbucht, die die " + "Mahngebühr abdecken; der Versand kann nicht zurückgesetzt werden." + ) + claim["items"] = [ + item for item in claim_items(claim) if str(item.get("item_id", "")) not in fee_item_ids + ] + claim["amount"] = money_text(claim_total(claim)) + reminder["status"] = "draft" + reminder["sent_at"] = None + reminder["payment_deadline"] = None + reminder["fee_item_ids"] = [] + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="reminder_sent_reverted", + summary=f"Versand zurückgesetzt: {reminder.get('name', '')}", + references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])}, + ) + return reminder + def register_reminder_document( self, member_id: str, diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 99cd081..d57091c 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -167,6 +167,10 @@ class ClaimTab(ttk.Frame): state="disabled", ) self.email_reminder_button.pack(side="right", padx=(8, 0)) + self.edit_reminder_button = ttk.Button( + buttons, text="Mahnung bearbeiten", command=self._edit_reminder, state="disabled" + ) + self.edit_reminder_button.pack(side="right", padx=(8, 0)) ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right") ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack( side="right", padx=(0, 8) @@ -438,6 +442,9 @@ class ClaimTab(ttk.Frame): self.send_reminder_button.configure(state=state) self.email_reminder_button.configure(state=state) self.discard_reminder_button.configure(state=state) + # Sent reminders can also be edited (revert-then-edit), just not cancelled ones. + can_edit = bool(reminder and str(reminder.get("status", "draft")) != "cancelled") + self.edit_reminder_button.configure(state="normal" if can_edit else "disabled") def _email_reminder(self) -> None: reminder = self._selected_reminder() @@ -467,6 +474,32 @@ class ClaimTab(ttk.Frame): return self._changed() + def _edit_reminder(self) -> None: + reminder = self._selected_reminder() + if not reminder: + return + if str(reminder.get("status", "")) == "sent": + if not messagebox.askyesno( + "Versand zurücksetzen", + "Diese Mahnung wurde bereits versandt. Zum Bearbeiten wird der Versand " + "zurückgesetzt (eine bereits gebuchte Mahngebühr wird von der Forderung " + "entfernt und die Zahlungsfrist gelöscht) -- anschließend muss die Mahnung " + "erneut versandt werden. Fortfahren?", + parent=self, + ): + return + try: + reminder = self.repository.revert_reminder_sent( + self.member_id, self.claim_id, str(reminder["reminder_id"]) + ) + except RepositoryError as exc: + messagebox.showerror("Versand konnte nicht zurückgesetzt werden", str(exc), parent=self) + return + self.refresh() + ReminderDialog( + self, self.repository, self.member_id, self.claim_id, self._changed, reminder=reminder + ) + def _discard_reminder(self) -> None: reminder = self._selected_reminder() if not reminder or not messagebox.askyesno( @@ -876,21 +909,25 @@ class ReminderDialog(_Dialog): 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) + def __init__(self, master, repository, member_id, claim_id, on_saved, *, reminder: dict | None = None): + super().__init__(master, "Mahnung bearbeiten" if reminder else "Mahnung vorbereiten", on_saved) # Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table # of freely-editable fee items whose content varies a lot -- make it resizable # so a long/multi-item Mahnung doesn't get cramped. self.resizable(True, True) self.repository, self.member_id, self.claim_id = repository, member_id, claim_id - 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 = max(existing_levels, default=0) + 1 + self.editing_reminder = reminder + if reminder: + self.level = int(reminder.get("level", 1)) + else: + 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 = max(existing_levels, default=0) + 1 self.policy = repository.get_reminder_policy() self.items: list[dict[str, str]] = [] @@ -902,7 +939,10 @@ class ReminderDialog(_Dialog): ) self.preset_by_label = self._build_presets() - self.preset_var = tk.StringVar(value=next(iter(self.preset_by_label))) + default_preset = ( + next(reversed(self.preset_by_label)) if reminder else next(iter(self.preset_by_label)) + ) + self.preset_var = tk.StringVar(value=default_preset) ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) preset_combo = ttk.Combobox( self.frame, @@ -942,7 +982,17 @@ class ReminderDialog(_Dialog): ).grid(row=5, column=1, sticky="ew", pady=5) self._build_items_table(row=6) - self._apply_preset() + if reminder: + self.name_var.set(str(reminder.get("name", ""))) + self.detail_var.set(str(reminder.get("detail", ""))) + self.deadline_var.set(str(reminder.get("payment_deadline_days", 14))) + self.channel_var.set( + display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", "email"))) + ) + self.items = [dict(item) for item in reminder.get("items") or []] + self._refresh_items() + else: + self._apply_preset() self._buttons(7, self._save) # Size to fit the fully built content (incl. the preset-populated items table) # instead of whatever size Tk guessed before everything was in place -- the @@ -963,16 +1013,23 @@ class ReminderDialog(_Dialog): fee = decimal_value(matching["fee"]) presets[f"Stufe {self.level}: {matching['name']}"] = { "name": matching["name"], + "detail": "", "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[f"Stufe {self.level}: Rücklastschrift"] = { "name": "Rücklastschrift", + # Prefilled so the member sees why they're being charged; the board can + # still append/edit a concrete reason (mangels Deckung, Konto ungültig, ...) + # right here since "Details" goes straight into the reminder email as-is. + "detail": "Leider konnte die Lastschrift von Ihrem Konto nicht eingelöst werden.", "payment_deadline_days": 14, "items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}], } - presets["Frei (eigene Eingabe)"] = {"name": "", "payment_deadline_days": 14, "items": []} + presets["Frei (eigene Eingabe)"] = { + "name": "", "detail": "", "payment_deadline_days": 14, "items": [] + } return presets def _default_amount_for(self, needle: str) -> str: @@ -984,6 +1041,7 @@ class ReminderDialog(_Dialog): def _apply_preset(self) -> None: preset = self.preset_by_label[self.preset_var.get()] self.name_var.set(preset["name"]) + self.detail_var.set(preset["detail"]) self.deadline_var.set(str(preset["payment_deadline_days"])) self.items = [dict(item) for item in preset["items"]] self._refresh_items() @@ -1112,16 +1170,28 @@ class ReminderDialog(_Dialog): ) return try: - self.repository.create_reminder_draft( - self.member_id, - self.claim_id, - level=self.level, - name=self.name_var.get(), - payment_deadline_days=deadline_days, - detail=self.detail_var.get(), - items=self.items, - channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()), - ) + if self.editing_reminder: + self.repository.edit_reminder_draft( + self.member_id, + self.claim_id, + str(self.editing_reminder["reminder_id"]), + name=self.name_var.get(), + payment_deadline_days=deadline_days, + detail=self.detail_var.get(), + items=self.items, + channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()), + ) + else: + self.repository.create_reminder_draft( + self.member_id, + self.claim_id, + level=self.level, + name=self.name_var.get(), + payment_deadline_days=deadline_days, + detail=self.detail_var.get(), + items=self.items, + channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()), + ) except (ValueError, RepositoryError) as exc: messagebox.showerror("Mahnungsentwurf konnte nicht gespeichert werden", str(exc), parent=self) return diff --git a/tests/test_reminders.py b/tests/test_reminders.py index ecca884..9e2aa35 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -281,6 +281,142 @@ def test_reminder_policy_rejects_invalid_input(tmp_path) -> None: ) +def test_edit_reminder_draft_updates_fields_but_not_level(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=[], + ) + + edited = repository.edit_reminder_draft( + member.member_id, + "claim-1", + draft["reminder_id"], + name="Erste Mahnung", + payment_deadline_days=10, + detail="Bitte um Ausgleich.", + items=[{"description": "Mahngebühr", "amount": "5.00"}], + ) + assert edited["level"] == 1 + assert edited["name"] == "Erste Mahnung" + assert edited["payment_deadline_days"] == 10 + assert edited["detail"] == "Bitte um Ausgleich." + assert edited["items"] == [{"description": "Mahngebühr", "amount": "5.00"}] + + +def test_edit_reminder_draft_rejects_already_sent_reminder(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=[], + ) + repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"]) + + with pytest.raises(RepositoryError, match="Nur ein Entwurf"): + repository.edit_reminder_draft( + member.member_id, + "claim-1", + draft["reminder_id"], + name="Andere Bezeichnung", + payment_deadline_days=14, + ) + + +def test_revert_reminder_sent_removes_booked_fee_and_reopens_draft(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"}], + ) + sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"]) + assert len(sent["fee_item_ids"]) == 1 + _data, claim = repository.get_claim(member.member_id, "claim-1") + assert claim_total(claim) == Decimal("105.00") + + reverted = repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"]) + assert reverted["status"] == "draft" + assert reverted["sent_at"] is None + assert reverted["payment_deadline"] is None + assert reverted["fee_item_ids"] == [] + + _data, claim = repository.get_claim(member.member_id, "claim-1") + assert claim_total(claim) == Decimal("100.00") + descriptions = {item["description"] for item in claim["items"]} + assert "Rücklastschriftgebühr" not in descriptions + + # Now editable and re-sendable, e.g. after fixing the wording. + repository.edit_reminder_draft( + member.member_id, + "claim-1", + draft["reminder_id"], + name="Rücklastschrift", + payment_deadline_days=14, + items=[{"description": "Rücklastschriftgebühr", "amount": "7.50"}], + ) + repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"]) + _data, claim = repository.get_claim(member.member_id, "claim-1") + assert claim_total(claim) == Decimal("107.50") + + +def test_revert_reminder_sent_rejects_when_higher_level_exists(tmp_path) -> None: + repository, member = _overdue_claim_repository(tmp_path) + first = repository.create_reminder_draft( + member.member_id, + "claim-1", + level=1, + name="Zahlungserinnerung", + payment_deadline_days=14, + items=[], + ) + repository.mark_reminder_sent(member.member_id, "claim-1", first["reminder_id"]) + repository.create_reminder_draft( + member.member_id, + "claim-1", + level=2, + name="Erste Mahnung", + payment_deadline_days=14, + items=[{"description": "Mahngebühr", "amount": "5.00"}], + ) + + with pytest.raises(RepositoryError, match="höhere Mahnstufe"): + repository.revert_reminder_sent(member.member_id, "claim-1", first["reminder_id"]) + + +def test_revert_reminder_sent_rejects_when_payment_already_covers_fee(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"}], + ) + repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"]) + repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-02-10", + amount="105.00", + allocation_amount="105.00", + ) + + with pytest.raises(RepositoryError, match="bereits Zahlungen verbucht"): + repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"]) + + def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() From 4ecdf87d054ad43fec4f1627f8896c916bbb345c Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 02:55:24 +0200 Subject: [PATCH 6/6] =?UTF-8?q?Document=20the=20multi-item/R=C3=BCcklastsc?= =?UTF-8?q?hrift=20reminder=20feature=20and=20reminder=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches up the changelog for the reminder items table, the configurable Mahnungen policy in Options, and today's ability to edit reminders (incl. undoing an already-sent one). Co-Authored-By: Claude Sonnet 5 --- src/ccma/assets/CHANGELOG.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index d309112..9fd70a5 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -29,7 +29,10 @@ "Der GnuCash-Import öffnet sich mit 80 % der Bildschirmgröße und erlaubt die Mehrfachauswahl von Buchungen per Klick, Strg+Klick oder Umschalt+Klick; Buchungen, die einer bereits vorhandenen Zahlung entsprechen, können beim Import übersprungen oder mit der Beschreibung aus GnuCash aktualisiert werden.", "Beim Anklicken einer noch nicht zugeordneten Forderung oder Spende im Zahlungsfenster wird der Betrag automatisch mit dem sinnvollen Vorschlag vorausgefüllt.", "Individuell abweichende Mitgliedsbeiträge (z. B. ermäßigter Beitrag für Schüler) können pro Mitglied mit Zeitraum, Begründung und wahlweise als fester Jahresbetrag oder prozentualer Ermäßigung hinterlegt werden; künftig erzeugte Beitragsforderungen berücksichtigen das monatsgenau, auch wenn die Ermäßigung mitten in einer Zahlungsperiode beginnt oder endet.", - "Der Hausmeister kann per Knopfdruck direkt aus der Mitgliederakte gezielt für ein einzelnes Mitglied ausgeführt werden, optional mit rückwirkender Erstellung von Beiträgen/Forderungen nur für diesen einen Lauf; alle anderen Mitglieder bleiben davon unberührt." + "Der Hausmeister kann per Knopfdruck direkt aus der Mitgliederakte gezielt für ein einzelnes Mitglied ausgeführt werden, optional mit rückwirkender Erstellung von Beiträgen/Forderungen nur für diesen einen Lauf; alle anderen Mitglieder bleiben davon unberührt.", + "Eine Mahnung kann statt einer einzelnen Gebühr mehrere frei bearbeitbare Positionen (Beschreibung & Betrag) enthalten; für eine nicht eingelöste Lastschrift steht eine eigene Rücklastschrift-Vorlage bereit, die dieselbe Mahnstufe wie eine reguläre Mahnung belegt und einen vorausgefüllten Hinweistext für die Mahnungs-E-Mail mitbringt.", + "Mahnstufen, Zahlungsfristen und Standardpositionen (Bezeichnung & Betrag) für Mahnungen lassen sich jetzt unter Optionen → Mahnungen zentral konfigurieren.", + "Mahnungsentwürfe können nachträglich bearbeitet werden; auch eine bereits als versandt markierte Mahnung lässt sich dafür sicher zurücksetzen (die gebuchte Mahngebühr wird wieder entfernt), sofern noch keine höhere Mahnstufe existiert oder Zahlungen bereits darauf verbucht wurden." ] }, {