mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-14 06:55:20 +02:00
Compare commits
28
Commits
7144d961c7
...
47f424252f
@@ -29,7 +29,14 @@
|
||||
"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 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.",
|
||||
"Eine überfällige Forderung, für die bereits eine Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr fälschlich als unbearbeiteter Fehler gemeldet, sondern als niedrig priorisierter Hinweis, dass die laufende Frist noch nicht abgelaufen ist.",
|
||||
"Geburtstags- und Mitgliedsjubiläums-Hinweise des Hausmeisters werden für Mitglieder mit beendeter Mitgliedschaft nicht mehr angezeigt.",
|
||||
"Eine überfällige Lastschrift, für die bereits eine Rücklastschrift-Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr weiterhin als überfällig gemeldet, sondern als niedrig priorisierter Hinweis, dass die Klärung noch läuft.",
|
||||
"Im SEPA-Export lassen sich einzelne Lastschriften per Mehrfachauswahl von der CSV-/XML-/Info-Mail-Erstellung ausschließen (z. B. während eine Rücklastschrift geklärt wird), statt immer alle offenen Lastschriften einziehen zu müssen; standardmäßig sind alle ausgewählt."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -8,6 +8,8 @@ ORDER = 20
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if member.status == "ended":
|
||||
return []
|
||||
try:
|
||||
birth_date = validate_birth_date(member.birth_date, today=context.today)
|
||||
except DateValidationError:
|
||||
|
||||
@@ -8,6 +8,8 @@ ORDER = 30
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if member.status == "ended":
|
||||
return []
|
||||
try:
|
||||
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit")
|
||||
except DateValidationError:
|
||||
|
||||
@@ -49,7 +49,7 @@ def evaluate(context: RuleContext) -> list[RuleAction]:
|
||||
escalation = None
|
||||
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
|
||||
if context.member.mandate_active:
|
||||
escalation = _sepa_action(context, claim, claim_id, days_overdue)
|
||||
escalation = _sepa_escalation(context, claim, claim_id, days_overdue)
|
||||
else:
|
||||
escalation = _reminder_action(context, claim, claim_id, levels, policy)
|
||||
actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
|
||||
@@ -72,6 +72,13 @@ def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_
|
||||
)
|
||||
|
||||
|
||||
def _sepa_escalation(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
|
||||
pending = _latest_pending_reminder(context.contributions.reminders, claim_id, context.today)
|
||||
if pending:
|
||||
return _sepa_awaiting_action(context, claim, claim_id, *pending)
|
||||
return _sepa_action(context, claim, claim_id, days_overdue)
|
||||
|
||||
|
||||
def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
return task(
|
||||
@@ -90,6 +97,47 @@ def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int)
|
||||
)
|
||||
|
||||
|
||||
def _sepa_awaiting_action(
|
||||
context: RuleContext, claim, claim_id: str, reminder: dict, trigger_date: date
|
||||
) -> RuleAction:
|
||||
"""A Rücklastschrift-Mahnung (or any reminder) was already sent for this SEPA
|
||||
claim and its own deadline hasn't expired yet -- staff already engaged, so this
|
||||
must not keep nagging "check the direct debit" as if nothing had happened."""
|
||||
name = str(reminder.get("name") or "Rücklastschrift")
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
return task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:sepa-awaiting",
|
||||
severity="info",
|
||||
code="sepa_debit_awaiting_deadline",
|
||||
title=f"{context.member.display_name}: {name} versandt, Rücklastschriftklärung läuft",
|
||||
detail=(
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
|
||||
),
|
||||
due_date=trigger_date,
|
||||
)
|
||||
|
||||
|
||||
def _latest_pending_reminder(
|
||||
reminders: list[dict], claim_id: str, today: date
|
||||
) -> tuple[dict, date] | None:
|
||||
best: tuple[dict, date] | None = None
|
||||
for item in reminders:
|
||||
if str(item.get("claim_id", "")) != claim_id or str(item.get("status", "")) != "sent":
|
||||
continue
|
||||
try:
|
||||
deadline = date.fromisoformat(str(item.get("payment_deadline", "")))
|
||||
except ValueError:
|
||||
continue
|
||||
if today > deadline:
|
||||
continue
|
||||
if best is None or deadline > best[1]:
|
||||
best = (item, deadline)
|
||||
return best
|
||||
|
||||
|
||||
def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None:
|
||||
reminders = [
|
||||
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
|
||||
@@ -105,8 +153,14 @@ def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy)
|
||||
return None
|
||||
level = int(next_level.get("level", 0))
|
||||
trigger_date = _trigger_date(claim, sent_levels, level, policy)
|
||||
if not trigger_date or context.today < trigger_date:
|
||||
if not trigger_date:
|
||||
return None
|
||||
if context.today < trigger_date:
|
||||
# A reminder for the previous level was already sent and its own payment
|
||||
# deadline hasn't expired yet -- this is being handled, not neglected, so it
|
||||
# must not show up as the same "error" severity as a plain unaddressed overdue
|
||||
# claim (that fallback is what the caller uses when this returns None).
|
||||
return _awaiting_deadline_action(context, claim, claim_id, level, sent_levels, trigger_date)
|
||||
draft_exists = any(
|
||||
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
|
||||
for item in reminders
|
||||
@@ -134,6 +188,28 @@ def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy)
|
||||
)
|
||||
|
||||
|
||||
def _awaiting_deadline_action(
|
||||
context: RuleContext, claim, claim_id: str, level: int, sent_levels: dict, trigger_date: date
|
||||
) -> RuleAction:
|
||||
previous_level = level - 1
|
||||
previous = sent_levels.get(previous_level) or {}
|
||||
name = str(previous.get("name") or f"Mahnstufe {previous_level}")
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
return task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:level-{previous_level}:awaiting",
|
||||
severity="info",
|
||||
code="reminder_awaiting_deadline",
|
||||
title=f"{context.member.display_name}: {name} versandt, Frist läuft noch",
|
||||
detail=(
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
|
||||
),
|
||||
due_date=trigger_date,
|
||||
)
|
||||
|
||||
|
||||
def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
|
||||
if level == 1:
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import math
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -67,9 +67,23 @@ class Housekeeper:
|
||||
today: date | None = None,
|
||||
*,
|
||||
member_delay: float = 0.0,
|
||||
member_id: str | None = None,
|
||||
retroactive_claims: bool | None = None,
|
||||
) -> list[HousekeeperFinding]:
|
||||
"""Run all rules. With `member_id`, only that member is (re-)evaluated --
|
||||
asset checks are skipped and every other member's tasks are left untouched
|
||||
(their rules never ran this pass, so they fall outside `successful_scopes`
|
||||
and `_resolve_stale_tasks` can't touch them). `retroactive_claims`, if given,
|
||||
overrides the configured setting for just this run without changing it
|
||||
permanently -- e.g. to recompute one member's full claim history on demand
|
||||
after a contribution change, without retroactively billing everyone else."""
|
||||
current_date = today or date.today()
|
||||
delay = _non_negative_delay(member_delay)
|
||||
effective_settings = (
|
||||
self.settings
|
||||
if retroactive_claims is None
|
||||
else replace(self.settings, retroactive_claims=retroactive_claims)
|
||||
)
|
||||
with _exclusive_lock(self.lock_path):
|
||||
original = self._load_state()
|
||||
working = copy.deepcopy(original)
|
||||
@@ -78,32 +92,39 @@ class Housekeeper:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
items = _items_by_key(working)
|
||||
successful_scopes: set[tuple[str, str]] = set()
|
||||
member_ids = set(self.repository.list_member_ids())
|
||||
all_member_ids = set(self.repository.list_member_ids())
|
||||
asset_ids = set(self.repository.list_asset_ids())
|
||||
_remove_orphaned_member_items(items, member_ids)
|
||||
_remove_orphaned_member_items(items, all_member_ids)
|
||||
_remove_orphaned_asset_items(items, asset_ids)
|
||||
|
||||
if member_id is not None:
|
||||
if member_id not in all_member_ids:
|
||||
raise RepositoryError(f"Mitglied nicht gefunden: {member_id}")
|
||||
selected_member_ids = {member_id}
|
||||
else:
|
||||
selected_member_ids = all_member_ids
|
||||
|
||||
rules = load_rules(self.repository.root)
|
||||
repository_config = self.repository.get_configuration()
|
||||
for index, member_id in enumerate(sorted(member_ids)):
|
||||
for index, current_member_id in enumerate(sorted(selected_member_ids)):
|
||||
if index and delay:
|
||||
time.sleep(delay)
|
||||
try:
|
||||
member, contributions = self.repository.preflight_member_record(member_id)
|
||||
member, contributions = self.repository.preflight_member_record(current_member_id)
|
||||
except RepositoryError as exc:
|
||||
self._refresh_record_integrity_task(items, member_id, str(exc), run_id, now)
|
||||
successful_scopes.add(("member-record-check", member_id))
|
||||
self._refresh_record_integrity_task(items, current_member_id, str(exc), run_id, now)
|
||||
successful_scopes.add(("member-record-check", current_member_id))
|
||||
continue
|
||||
successful_scopes.add(("member-record-check", member_id))
|
||||
successful_scopes.add(("member-record-check", current_member_id))
|
||||
self._refresh_hash_integrity_tasks(
|
||||
items,
|
||||
target_type="member",
|
||||
target_id=member_id,
|
||||
warnings=self.repository.member_hash_warnings(member_id),
|
||||
target_id=current_member_id,
|
||||
warnings=self.repository.member_hash_warnings(current_member_id),
|
||||
run_id=run_id,
|
||||
now=now,
|
||||
)
|
||||
successful_scopes.add(("member-hash-check", member_id))
|
||||
successful_scopes.add(("member-hash-check", current_member_id))
|
||||
for rule in rules:
|
||||
scope = (rule.rule_id, member.member_id)
|
||||
try:
|
||||
@@ -111,7 +132,7 @@ class Housekeeper:
|
||||
member=member,
|
||||
contributions=contributions,
|
||||
today=current_date,
|
||||
settings=self.settings,
|
||||
settings=effective_settings,
|
||||
repository_config=repository_config,
|
||||
)
|
||||
actions = rule.evaluate(context)
|
||||
@@ -141,16 +162,17 @@ class Housekeeper:
|
||||
else:
|
||||
successful_scopes.add(scope)
|
||||
|
||||
for asset_id in sorted(asset_ids):
|
||||
self._refresh_hash_integrity_tasks(
|
||||
items,
|
||||
target_type="asset",
|
||||
target_id=asset_id,
|
||||
warnings=self.repository.asset_hash_warnings(asset_id),
|
||||
run_id=run_id,
|
||||
now=now,
|
||||
)
|
||||
successful_scopes.add(("asset-hash-check", asset_id))
|
||||
if member_id is None:
|
||||
for asset_id in sorted(asset_ids):
|
||||
self._refresh_hash_integrity_tasks(
|
||||
items,
|
||||
target_type="asset",
|
||||
target_id=asset_id,
|
||||
warnings=self.repository.asset_hash_warnings(asset_id),
|
||||
run_id=run_id,
|
||||
now=now,
|
||||
)
|
||||
successful_scopes.add(("asset-hash-check", asset_id))
|
||||
|
||||
self._resolve_stale_tasks(items, successful_scopes, run_id, now)
|
||||
working.update(
|
||||
|
||||
@@ -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:",
|
||||
|
||||
+236
-26
@@ -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,
|
||||
@@ -25,6 +27,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 +68,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 +81,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 +128,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 +1982,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 +2030,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 +2041,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 +2056,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 +2088,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
|
||||
|
||||
@@ -2092,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,
|
||||
@@ -2410,6 +2519,94 @@ 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"],
|
||||
)
|
||||
# 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 standard_items_raw
|
||||
]
|
||||
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 +2841,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"):
|
||||
|
||||
+309
-45
@@ -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
|
||||
@@ -166,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)
|
||||
@@ -348,7 +353,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
|
||||
@@ -437,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()
|
||||
@@ -466,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(
|
||||
@@ -867,67 +901,297 @@ class AllocateCreditDialog(_Dialog):
|
||||
|
||||
|
||||
class ReminderDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnung vorbereiten", on_saved)
|
||||
"""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, *, 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
|
||||
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
|
||||
}
|
||||
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.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]] = []
|
||||
|
||||
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))
|
||||
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()
|
||||
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,
|
||||
textvariable=self.level_var,
|
||||
values=list(self.definition_by_label),
|
||||
textvariable=self.preset_var,
|
||||
values=list(self.preset_by_label),
|
||||
state="readonly",
|
||||
width=38,
|
||||
width=55,
|
||||
)
|
||||
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
|
||||
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
|
||||
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
|
||||
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
|
||||
preset_combo.grid(row=1, column=1, sticky="ew", pady=5)
|
||||
preset_combo.bind("<<ComboboxSelected>>", lambda _event: self._apply_preset())
|
||||
|
||||
self.name_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Bezeichnung").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.name_var, width=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=64).grid(
|
||||
row=3, column=1, sticky="ew", pady=5
|
||||
)
|
||||
|
||||
self.deadline_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
|
||||
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
row=4, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
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))
|
||||
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)
|
||||
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
|
||||
# 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 = max(700, self.winfo_reqwidth())
|
||||
height = self.winfo_reqheight()
|
||||
self.geometry(f"{width}x{height}")
|
||||
self.minsize(width, height)
|
||||
|
||||
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"],
|
||||
"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": "", "detail": "", "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.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()
|
||||
|
||||
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)
|
||||
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=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("<<TreeviewSelect>>", lambda _event: self._load_selected_item())
|
||||
|
||||
# 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=1)
|
||||
edit_row.columnconfigure(1, weight=0)
|
||||
self.item_description_var = tk.StringVar()
|
||||
description_combo = ttk.Combobox(
|
||||
edit_row,
|
||||
textvariable=self.item_description_var,
|
||||
values=[str(entry.get("description", "")) for entry in self.policy["standard_fee_items"]],
|
||||
)
|
||||
description_combo.grid(row=0, column=0, sticky="ew", padx=(0, 8))
|
||||
description_combo.bind("<<ComboboxSelected>>", 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(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))
|
||||
ttk.Button(buttons_row, text="Position hinzufügen", command=self._add_item).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
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()
|
||||
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 _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:
|
||||
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 _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):
|
||||
definition = self.definition_by_label[self.level_var.get()]
|
||||
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()),
|
||||
detail=self.detail_var.get(),
|
||||
fee=self.fee_var.get(),
|
||||
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -302,6 +302,7 @@ class MainWindow(ttk.Frame):
|
||||
on_open_assets=self.open_assets,
|
||||
on_open_asset=self.open_asset,
|
||||
on_return_asset=self.return_asset,
|
||||
on_run_housekeeper=self.run_housekeeper_for_member,
|
||||
)
|
||||
self.tabs.add(
|
||||
key,
|
||||
@@ -503,6 +504,16 @@ class MainWindow(ttk.Frame):
|
||||
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
|
||||
return self.findings
|
||||
|
||||
def run_housekeeper_for_member(
|
||||
self, member_id: str, retroactive_claims: bool = False
|
||||
) -> list[HousekeeperFinding]:
|
||||
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run(
|
||||
member_id=member_id, retroactive_claims=retroactive_claims or None
|
||||
)
|
||||
self.refresh_overview(run_housekeeper=False)
|
||||
self.status_var.set(f"Hausmeisterlauf für Mitglied beendet: {len(self.findings)} Vorgänge insgesamt.")
|
||||
return self.findings
|
||||
|
||||
def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]:
|
||||
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key)
|
||||
self.refresh_overview(run_housekeeper=False)
|
||||
|
||||
@@ -21,7 +21,7 @@ from ccma.domain.contributions import (
|
||||
payment_allocated_total,
|
||||
)
|
||||
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display, format_month_for_display
|
||||
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event
|
||||
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event, HousekeeperFinding
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
|
||||
@@ -67,6 +67,49 @@ def _claim_sort_value(data, claim: dict, column: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class _RunHousekeeperDialog(tk.Toplevel):
|
||||
"""Confirms a housekeeper run scoped to a single member, with the option to
|
||||
force retroactive claim creation for just this run without touching the
|
||||
application-wide default setting."""
|
||||
|
||||
def __init__(self, master: tk.Misc, member_name: str, on_confirm: Callable[[bool], None]):
|
||||
super().__init__(master)
|
||||
self.on_confirm = on_confirm
|
||||
self.title("Hausmeister für dieses Mitglied ausführen")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
frame = ttk.Frame(self, padding=18)
|
||||
frame.pack(fill="both", expand=True)
|
||||
ttk.Label(
|
||||
frame,
|
||||
text=(
|
||||
f"Alle Regeln werden ausschließlich für {member_name} neu ausgeführt. "
|
||||
"Andere Mitglieder bleiben unberührt."
|
||||
),
|
||||
wraplength=380,
|
||||
justify="left",
|
||||
).pack(anchor="w", pady=(0, 12))
|
||||
self.retroactive_var = tk.BooleanVar(value=False)
|
||||
ttk.Checkbutton(
|
||||
frame,
|
||||
text="Rückwirkende Beiträge/Forderungen erstellen",
|
||||
variable=self.retroactive_var,
|
||||
).pack(anchor="w", pady=(0, 14))
|
||||
buttons = ttk.Frame(frame)
|
||||
buttons.pack(anchor="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Ausführen", style="Accent.TButton", command=self._confirm).pack(
|
||||
side="left"
|
||||
)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _confirm(self) -> None:
|
||||
retroactive = self.retroactive_var.get()
|
||||
self.destroy()
|
||||
self.on_confirm(retroactive)
|
||||
|
||||
|
||||
class MemberTab(ttk.Frame):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -80,6 +123,7 @@ class MemberTab(ttk.Frame):
|
||||
on_open_assets: Callable[[], None],
|
||||
on_open_asset: Callable[[str], None],
|
||||
on_return_asset: Callable[[str], None],
|
||||
on_run_housekeeper: Callable[[str, bool], list[HousekeeperFinding]],
|
||||
):
|
||||
super().__init__(master, padding=12)
|
||||
self.repository = repository
|
||||
@@ -91,6 +135,7 @@ class MemberTab(ttk.Frame):
|
||||
self.on_open_assets = on_open_assets
|
||||
self.on_open_asset = on_open_asset
|
||||
self.on_return_asset = on_return_asset
|
||||
self.on_run_housekeeper = on_run_housekeeper
|
||||
self.member = repository.get_member(member_id)
|
||||
self.variables: dict[str, tk.Variable] = {}
|
||||
self._field_sections: dict[str, str] = {}
|
||||
@@ -124,9 +169,14 @@ class MemberTab(ttk.Frame):
|
||||
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
|
||||
row=2, column=0, sticky="w", pady=(3, 0)
|
||||
)
|
||||
ttk.Button(header, text="Tab schließen", command=self._close).grid(
|
||||
row=0, column=1, sticky="ne", padx=(12, 0)
|
||||
)
|
||||
header_actions = ttk.Frame(header)
|
||||
header_actions.grid(row=0, column=1, sticky="ne", padx=(12, 0))
|
||||
ttk.Button(
|
||||
header_actions,
|
||||
text="Hausmeister für dieses Mitglied",
|
||||
command=self._run_housekeeper_for_member,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(header_actions, text="Tab schließen", command=self._close).pack(side="left")
|
||||
self.messages = MessageBannerList(self)
|
||||
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
||||
self.messages.grid_remove()
|
||||
@@ -563,6 +613,25 @@ class MemberTab(ttk.Frame):
|
||||
return
|
||||
self.on_close()
|
||||
|
||||
def _run_housekeeper_for_member(self) -> None:
|
||||
_RunHousekeeperDialog(self, self.member.display_name, self._execute_housekeeper_run)
|
||||
|
||||
def _execute_housekeeper_run(self, retroactive: bool) -> None:
|
||||
try:
|
||||
findings = self.on_run_housekeeper(self.member_id, retroactive)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Hausmeisterlauf fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
member_findings = [item for item in findings if item.member_id == self.member_id]
|
||||
messagebox.showinfo(
|
||||
"Hausmeister ausgeführt",
|
||||
f"Hausmeisterlauf für {self.member.display_name} abgeschlossen. "
|
||||
f"{len(member_findings)} offene(r) Vorgang/Vorgänge für dieses Mitglied.",
|
||||
parent=self,
|
||||
)
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
def _build_timeline(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(1, weight=1)
|
||||
|
||||
@@ -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,
|
||||
@@ -60,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("<Escape>", 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:
|
||||
@@ -84,18 +93,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 +348,234 @@ class OptionsDialog(tk.Toplevel):
|
||||
preview = f"UNGÜLTIG: {exc}"
|
||||
self.number_preview_var.set(preview)
|
||||
|
||||
def _build_reminders(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
policy = self.repository.get_reminder_policy()
|
||||
self.grace_days_var = tk.StringVar(value=str(policy["grace_days_after_due"]))
|
||||
ttk.Label(parent, text="Kulanzfrist nach Fälligkeit (Tage)").grid(
|
||||
row=0, column=0, sticky="w", pady=6
|
||||
)
|
||||
ttk.Spinbox(parent, from_=0, to=365, textvariable=self.grace_days_var, width=6).grid(
|
||||
row=0, column=1, sticky="w", pady=6
|
||||
)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text="Ab wann nach Fälligkeit die erste Mahnstufe fällig wird.",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
|
||||
|
||||
levels_frame = ttk.LabelFrame(parent, text="Mahnstufen", padding=10)
|
||||
levels_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(4, 0))
|
||||
levels_frame.columnconfigure(0, weight=1)
|
||||
self.levels = [dict(level) for level in policy["levels"]]
|
||||
self.levels_tree = ttk.Treeview(
|
||||
levels_frame, columns=("level", "name", "fee", "deadline"), show="headings", height=5
|
||||
)
|
||||
for key, title, width in (
|
||||
("level", "Stufe", 55),
|
||||
("name", "Name", 220),
|
||||
("fee", "Gebühr", 90),
|
||||
("deadline", "Frist (Tage)", 100),
|
||||
):
|
||||
self.levels_tree.heading(key, text=title)
|
||||
self.levels_tree.column(key, width=width, anchor="w")
|
||||
self.levels_tree.grid(row=0, column=0, sticky="ew")
|
||||
self.levels_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_level())
|
||||
|
||||
level_controls = ttk.Frame(levels_frame)
|
||||
level_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
self.level_name_var = tk.StringVar()
|
||||
self.level_fee_var = tk.StringVar()
|
||||
self.level_deadline_var = tk.StringVar()
|
||||
ttk.Entry(level_controls, textvariable=self.level_name_var, width=22).pack(side="left", padx=(0, 6))
|
||||
ttk.Entry(level_controls, textvariable=self.level_fee_var, width=8).pack(side="left", padx=(0, 6))
|
||||
ttk.Entry(level_controls, textvariable=self.level_deadline_var, width=8).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(level_controls, text="Übernehmen", command=self._apply_level).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(level_controls, text="Hinzufügen", command=self._add_level).pack(side="left", padx=(0, 6))
|
||||
ttk.Button(level_controls, text="Entfernen", command=self._remove_level).pack(side="left")
|
||||
ttk.Label(
|
||||
parent,
|
||||
text="Stufen werden immer nach ihrer Reihenfolge in der Liste 1, 2, 3, … nummeriert.",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
||||
|
||||
items_frame = ttk.LabelFrame(parent, text="Standardpositionen (Beschreibung & Betrag)", padding=10)
|
||||
items_frame.grid(row=4, column=0, columnspan=2, sticky="nsew", pady=(16, 0))
|
||||
items_frame.columnconfigure(0, weight=1)
|
||||
self.standard_items = [dict(item) for item in policy["standard_fee_items"]]
|
||||
self.standard_items_tree = ttk.Treeview(
|
||||
items_frame, columns=("description", "amount"), show="headings", height=5
|
||||
)
|
||||
self.standard_items_tree.heading("description", text="Beschreibung")
|
||||
self.standard_items_tree.heading("amount", text="Standardbetrag")
|
||||
self.standard_items_tree.column("description", width=260, anchor="w")
|
||||
self.standard_items_tree.column("amount", width=120, anchor="w")
|
||||
self.standard_items_tree.grid(row=0, column=0, sticky="ew")
|
||||
self.standard_items_tree.bind(
|
||||
"<<TreeviewSelect>>", lambda _event: self._load_selected_standard_item()
|
||||
)
|
||||
|
||||
item_controls = ttk.Frame(items_frame)
|
||||
item_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
self.standard_item_description_var = tk.StringVar()
|
||||
self.standard_item_amount_var = tk.StringVar()
|
||||
ttk.Entry(item_controls, textvariable=self.standard_item_description_var, width=26).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Entry(item_controls, textvariable=self.standard_item_amount_var, width=10).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Übernehmen", command=self._apply_standard_item).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Hinzufügen", command=self._add_standard_item).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Entfernen", command=self._remove_standard_item).pack(side="left")
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Diese Vorlagen stehen bei Mahnungen und Rücklastschriften als Positionen zur "
|
||||
"Auswahl, z. B. Mahngebühr, Rücklastschriftgebühr, Bankgebühr oder Porto."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=560,
|
||||
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
||||
|
||||
self._refresh_levels()
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _refresh_levels(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
self.levels_tree.delete(*self.levels_tree.get_children())
|
||||
for index, level in enumerate(self.levels):
|
||||
self.levels_tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(
|
||||
index + 1,
|
||||
level.get("name", ""),
|
||||
f"{level.get('fee', '0.00')} EUR",
|
||||
level.get("payment_deadline_days", 14),
|
||||
),
|
||||
)
|
||||
if selected and self.levels_tree.exists(selected[0]):
|
||||
self.levels_tree.selection_set(selected[0])
|
||||
|
||||
def _load_selected_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
level = self.levels[int(selected[0])]
|
||||
self.level_name_var.set(level.get("name", ""))
|
||||
self.level_fee_var.set(str(level.get("fee", "0.00")))
|
||||
self.level_deadline_var.set(str(level.get("payment_deadline_days", 14)))
|
||||
|
||||
def _apply_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
|
||||
return
|
||||
name = self.level_name_var.get().strip()
|
||||
if not name:
|
||||
messagebox.showerror("Name fehlt", "Bitte einen Namen angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
fee = decimal_value(self.level_fee_var.get(), "Gebühr")
|
||||
deadline = int(self.level_deadline_var.get())
|
||||
except (ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
|
||||
return
|
||||
self.levels[int(selected[0])] = {
|
||||
"name": name,
|
||||
"fee": money_text(fee),
|
||||
"payment_deadline_days": deadline,
|
||||
}
|
||||
self._refresh_levels()
|
||||
|
||||
def _add_level(self) -> None:
|
||||
name = self.level_name_var.get().strip() or "Neue Mahnstufe"
|
||||
try:
|
||||
fee = decimal_value(self.level_fee_var.get() or "0", "Gebühr")
|
||||
deadline = int(self.level_deadline_var.get() or "14")
|
||||
except (ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
|
||||
return
|
||||
self.levels.append({"name": name, "fee": money_text(fee), "payment_deadline_days": deadline})
|
||||
self._refresh_levels()
|
||||
|
||||
def _remove_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
|
||||
return
|
||||
del self.levels[int(selected[0])]
|
||||
self._refresh_levels()
|
||||
|
||||
def _refresh_standard_items(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
self.standard_items_tree.delete(*self.standard_items_tree.get_children())
|
||||
for index, item in enumerate(self.standard_items):
|
||||
self.standard_items_tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(item.get("description", ""), f"{item.get('default_amount', '0.00')} EUR"),
|
||||
)
|
||||
if selected and self.standard_items_tree.exists(selected[0]):
|
||||
self.standard_items_tree.selection_set(selected[0])
|
||||
|
||||
def _load_selected_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
item = self.standard_items[int(selected[0])]
|
||||
self.standard_item_description_var.set(item.get("description", ""))
|
||||
self.standard_item_amount_var.set(str(item.get("default_amount", "0.00")))
|
||||
|
||||
def _apply_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
description = self.standard_item_description_var.get().strip()
|
||||
if not description:
|
||||
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.standard_item_amount_var.get(), "Standardbetrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.standard_items[int(selected[0])] = {
|
||||
"description": description,
|
||||
"default_amount": money_text(amount),
|
||||
}
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _add_standard_item(self) -> None:
|
||||
description = self.standard_item_description_var.get().strip() or "Neue Position"
|
||||
try:
|
||||
amount = decimal_value(self.standard_item_amount_var.get() or "0", "Standardbetrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.standard_items.append({"description": description, "default_amount": money_text(amount)})
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _remove_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
del self.standard_items[int(selected[0])]
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _choose_store(self) -> None:
|
||||
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
|
||||
initial = current if current.is_dir() else current.parent
|
||||
@@ -377,6 +617,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 +680,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
|
||||
|
||||
+60
-12
@@ -40,7 +40,7 @@ class SepaExportDialog(tk.Toplevel):
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(2, weight=1)
|
||||
self.rowconfigure(3, weight=1)
|
||||
filters = ttk.Frame(self, padding=12)
|
||||
filters.grid(row=0, column=0, sticky="ew")
|
||||
ttk.Label(filters, text="Fällig ab").grid(row=0, column=0)
|
||||
@@ -57,10 +57,23 @@ class SepaExportDialog(tk.Toplevel):
|
||||
)
|
||||
ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6)
|
||||
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
|
||||
row=1, column=0, sticky="w", padx=12, pady=(0, 8)
|
||||
row=1, column=0, sticky="w", padx=12, pady=(0, 2)
|
||||
)
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Standardmäßig sind alle Lastschriften ausgewählt -- einzelne mit Strg+Klick "
|
||||
"abwählen (z. B. während eine Rücklastschrift geklärt wird), um sie erst bei "
|
||||
"einem späteren Einzug zu berücksichtigen."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=960,
|
||||
).grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8))
|
||||
self.table = ttk.Treeview(
|
||||
self, columns=("number", "name", "iban", "mandate", "purpose", "amount"), show="headings"
|
||||
self,
|
||||
columns=("number", "name", "iban", "mandate", "purpose", "amount"),
|
||||
show="headings",
|
||||
selectmode="extended",
|
||||
)
|
||||
for key, label, width, anchor in (
|
||||
("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"),
|
||||
@@ -69,9 +82,10 @@ class SepaExportDialog(tk.Toplevel):
|
||||
):
|
||||
self.table.heading(key, text=label)
|
||||
self.table.column(key, width=width, anchor=anchor)
|
||||
self.table.grid(row=2, column=0, sticky="nsew", padx=12)
|
||||
self.table.grid(row=3, column=0, sticky="nsew", padx=12)
|
||||
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
|
||||
footer = ttk.Frame(self, padding=12)
|
||||
footer.grid(row=3, column=0, sticky="ew")
|
||||
footer.grid(row=4, column=0, sticky="ew")
|
||||
footer.columnconfigure(0, weight=1)
|
||||
ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w")
|
||||
ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid(
|
||||
@@ -107,6 +121,14 @@ class SepaExportDialog(tk.Toplevel):
|
||||
return due_from, due, collection
|
||||
|
||||
def refresh(self) -> None:
|
||||
# Preserve which members were deliberately deselected across a refresh (e.g.
|
||||
# _prepare() re-querying right before an export) by identity, not by row index
|
||||
# -- a debit newly appearing in the requeried list still defaults to selected.
|
||||
previously_deselected = {
|
||||
self.debits[int(iid)].member_id
|
||||
for iid in self.table.get_children()
|
||||
if iid not in self.table.selection()
|
||||
}
|
||||
try:
|
||||
due_from, due, _collection = self._dates()
|
||||
self.debits, self.warnings = pending_direct_debits(
|
||||
@@ -116,18 +138,39 @@ class SepaExportDialog(tk.Toplevel):
|
||||
messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self)
|
||||
return
|
||||
self.table.delete(*self.table.get_children())
|
||||
for debit in self.debits:
|
||||
for index, debit in enumerate(self.debits):
|
||||
self.table.insert(
|
||||
"", "end",
|
||||
"", "end", iid=str(index),
|
||||
values=(debit.member_number, debit.account_holder, debit.iban,
|
||||
debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"),
|
||||
)
|
||||
total = sum((item.amount for item in self.debits), Decimal("0"))
|
||||
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR")
|
||||
self.table.selection_set(
|
||||
[
|
||||
str(index)
|
||||
for index, debit in enumerate(self.debits)
|
||||
if debit.member_id not in previously_deselected
|
||||
]
|
||||
)
|
||||
self._update_summary()
|
||||
self.warning_var.set(
|
||||
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else ""
|
||||
)
|
||||
|
||||
def _selected_debits(self) -> list:
|
||||
return [self.debits[int(iid)] for iid in sorted(self.table.selection(), key=int)]
|
||||
|
||||
def _update_summary(self) -> None:
|
||||
selected = self._selected_debits()
|
||||
total = sum((item.amount for item in self.debits), Decimal("0"))
|
||||
if len(selected) == len(self.debits):
|
||||
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR")
|
||||
return
|
||||
selected_total = sum((item.amount for item in selected), Decimal("0"))
|
||||
self.summary_var.set(
|
||||
f"{len(selected)} von {len(self.debits)} Lastschrift(en) ausgewählt · "
|
||||
f"Summe {selected_total:.2f} EUR (gesamt {total:.2f} EUR)"
|
||||
)
|
||||
|
||||
def _prepare(self) -> tuple[date, date, date] | None:
|
||||
self.refresh()
|
||||
if not self.debits:
|
||||
@@ -135,6 +178,11 @@ class SepaExportDialog(tk.Toplevel):
|
||||
"Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self
|
||||
)
|
||||
return None
|
||||
if not self._selected_debits():
|
||||
messagebox.showinfo(
|
||||
"Keine Auswahl", "Bitte mindestens eine Lastschrift auswählen.", parent=self
|
||||
)
|
||||
return None
|
||||
if self.warnings and not messagebox.askyesno(
|
||||
"Unvollständige Mandate",
|
||||
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
|
||||
@@ -157,7 +205,7 @@ class SepaExportDialog(tk.Toplevel):
|
||||
return
|
||||
target = self._target(".csv", "CSV-Datei", dates[2])
|
||||
if target:
|
||||
write_csv(target, self.debits, collection_date=dates[2])
|
||||
write_csv(target, self._selected_debits(), collection_date=dates[2])
|
||||
messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self)
|
||||
|
||||
def _save_xml(self) -> None:
|
||||
@@ -169,7 +217,7 @@ class SepaExportDialog(tk.Toplevel):
|
||||
return
|
||||
try:
|
||||
write_pain008(
|
||||
target, self.debits, collection_date=dates[2],
|
||||
target, self._selected_debits(), collection_date=dates[2],
|
||||
organization=self.repository.get_configuration().get("organization") or {},
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
@@ -180,7 +228,7 @@ class SepaExportDialog(tk.Toplevel):
|
||||
def _open_mail_export(self) -> None:
|
||||
dates = self._prepare()
|
||||
if dates:
|
||||
SepaMailExportDialog(self, self.repository, self.debits, dates[2])
|
||||
SepaMailExportDialog(self, self.repository, self._selected_debits(), dates[2])
|
||||
|
||||
|
||||
class SepaMailExportDialog(tk.Toplevel):
|
||||
|
||||
@@ -345,13 +345,13 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
detail="Per E-Mail versandt",
|
||||
fee="5.00",
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
|
||||
assert claim_total(claim) == Decimal("100.00")
|
||||
assert reminder["status"] == "draft"
|
||||
assert reminder["fee_item_id"] is None
|
||||
assert reminder["fee_item_ids"] == []
|
||||
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", reminder["reminder_id"])
|
||||
data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
@@ -360,7 +360,7 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
|
||||
assert claim_total(claim) == Decimal("105.00")
|
||||
assert sent["status"] == "sent"
|
||||
assert sent["payment_deadline"]
|
||||
assert sent["fee_item_id"]
|
||||
assert sent["fee_item_ids"]
|
||||
assert data.reminders[0]["detail"] == "Per E-Mail versandt"
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent"
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
||||
level=1,
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=14,
|
||||
fee="5.00",
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
|
||||
+336
-2
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
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 +45,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")
|
||||
@@ -58,6 +61,35 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
||||
assert "Erste Mahnung" in next_task.title
|
||||
|
||||
|
||||
def test_overdue_claim_awaiting_reminder_deadline_is_not_reported_as_error(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
deadline = date.fromisoformat(sent["payment_deadline"])
|
||||
|
||||
findings = [
|
||||
item
|
||||
for item in housekeeper.run(today=deadline - timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
# A reminder was already sent and its own deadline hasn't expired yet -- this is
|
||||
# being handled, so it must not appear as an unaddressed "error"-severity overdue
|
||||
# claim, only as a low-priority note that the existing deadline is still running.
|
||||
assert not any(item.code == "claim_overdue" for item in findings)
|
||||
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
|
||||
assert awaiting.severity == "info"
|
||||
assert "Zahlungserinnerung" in awaiting.title
|
||||
|
||||
|
||||
def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
@@ -120,6 +152,47 @@ def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
|
||||
assert "SEPA" in sepa_finding.detail
|
||||
|
||||
|
||||
def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDATE-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
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"])
|
||||
deadline = date.fromisoformat(sent["payment_deadline"])
|
||||
|
||||
findings = [
|
||||
item
|
||||
for item in housekeeper.run(today=deadline - timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
assert not any(item.code == "sepa_debit_overdue" for item in findings)
|
||||
awaiting = next(item for item in findings if item.code == "sepa_debit_awaiting_deadline")
|
||||
assert awaiting.severity == "info"
|
||||
assert "Rücklastschrift" in awaiting.title
|
||||
|
||||
# Once the reminder's own deadline has passed without resolution, the plain SEPA
|
||||
# follow-up notice returns so it doesn't just silently go quiet forever.
|
||||
findings = [
|
||||
item for item in housekeeper.run(today=deadline + timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
assert any(item.code == "sepa_debit_overdue" for item in findings)
|
||||
assert not any(item.code == "sepa_debit_awaiting_deadline" for item in findings)
|
||||
|
||||
|
||||
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
@@ -155,7 +228,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 +246,264 @@ 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=[],
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
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"] == []
|
||||
|
||||
+98
-1
@@ -8,7 +8,7 @@ import pytest
|
||||
import ccma.services.housekeeper as housekeeper_module
|
||||
from ccma.rules.loader import RuleLoadError
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.storage.repository import MemberRepository
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def test_housekeeper_optionally_waits_between_members(tmp_path, monkeypatch) -> None:
|
||||
@@ -236,6 +236,39 @@ def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -
|
||||
assert "membership-fee:2027:annual" not in claim_keys
|
||||
|
||||
|
||||
def test_housekeeper_skips_birthday_and_anniversary_for_ended_members(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
active = repository.create_member(first_name="Active", last_name="Member", birth_date="1990-06-21")
|
||||
active.status = "active"
|
||||
active.accepted_at = "2025-06-21"
|
||||
active.membership_started_at = "2025-06-21"
|
||||
repository.save_member(active)
|
||||
ended = repository.create_member(first_name="Ended", last_name="Member", birth_date="1990-06-21")
|
||||
ended.status = "ended"
|
||||
ended.accepted_at = "2025-06-21"
|
||||
ended.membership_started_at = "2025-06-21"
|
||||
ended.membership_ended_at = "2025-12-31"
|
||||
repository.save_member(ended)
|
||||
|
||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||
birthday_days_before=0,
|
||||
birthday_days_after=0,
|
||||
anniversary_days_before=0,
|
||||
anniversary_days_after=0,
|
||||
anniversary_intervals="1Y",
|
||||
)
|
||||
|
||||
findings = Housekeeper(repository, settings).run(today=date(2026, 6, 21))
|
||||
codes_by_member = {}
|
||||
for item in findings:
|
||||
codes_by_member.setdefault(item.member_id, set()).add(item.code)
|
||||
|
||||
assert "birthday" in codes_by_member.get(active.member_id, set())
|
||||
assert "membership_anniversary" in codes_by_member.get(active.member_id, set())
|
||||
assert codes_by_member.get(ended.member_id, set()).isdisjoint({"birthday", "membership_anniversary"})
|
||||
|
||||
|
||||
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -381,3 +414,67 @@ def test_housekeeper_removes_items_for_deleted_member_directory(tmp_path) -> Non
|
||||
|
||||
assert findings == []
|
||||
assert not any(item.get("member_id") == member.member_id for item in state["items"])
|
||||
|
||||
|
||||
def test_housekeeper_run_can_target_a_single_member(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member_a = repository.create_member(first_name="Alpha", last_name="Member")
|
||||
member_b = repository.create_member(first_name="Beta", last_name="Member")
|
||||
housekeeper = Housekeeper(repository)
|
||||
housekeeper.run(today=date(2026, 6, 21))
|
||||
state_before = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
|
||||
b_task_before = next(
|
||||
item for item in state_before["items"] if item.get("member_id") == member_b.member_id
|
||||
)
|
||||
|
||||
member_a.birth_date = "1990-01-01"
|
||||
repository.save_member(member_a)
|
||||
findings = housekeeper.run(today=date(2026, 6, 21), member_id=member_a.member_id)
|
||||
|
||||
a_findings = [item for item in findings if item.member_id == member_a.member_id]
|
||||
assert not any(item.code == "missing_birth_date" for item in a_findings)
|
||||
state_after = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
|
||||
b_task_after = next(
|
||||
item for item in state_after["items"] if item.get("member_id") == member_b.member_id
|
||||
)
|
||||
assert b_task_after == b_task_before
|
||||
|
||||
|
||||
def test_housekeeper_run_rejects_unknown_member_id(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
with pytest.raises(RepositoryError, match="nicht gefunden"):
|
||||
housekeeper.run(today=date(2026, 6, 21), member_id="does-not-exist")
|
||||
|
||||
|
||||
def test_housekeeper_run_retroactive_override_applies_only_to_that_run(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Retro", last_name="Member", birth_date="1990-01-01")
|
||||
member.status = "active"
|
||||
member.accepted_at = "2024-04-15"
|
||||
member.membership_started_at = "2024-04-15"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
housekeeper.run(today=date(2026, 6, 21), member_id=member.member_id, retroactive_claims=True)
|
||||
|
||||
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
|
||||
assert "membership-fee:2024:annual" in claim_keys
|
||||
assert housekeeper.settings.retroactive_claims is False
|
||||
|
||||
other = repository.create_member(first_name="NoRetro", last_name="Member", birth_date="1990-01-01")
|
||||
other.status = "active"
|
||||
other.accepted_at = "2024-04-15"
|
||||
other.membership_started_at = "2024-04-15"
|
||||
other.payment_frequency = "annual"
|
||||
repository.save_member(other)
|
||||
housekeeper.run(today=date(2026, 6, 21), member_id=other.member_id)
|
||||
|
||||
other_claim_keys = {claim["claim_key"] for claim in repository.get_contributions(other.member_id).claims}
|
||||
assert "membership-fee:2024:annual" not in other_claim_keys
|
||||
assert "membership-fee:2026:annual" in other_claim_keys
|
||||
|
||||
Reference in New Issue
Block a user