Compare commits

...
28 Commits
Author SHA1 Message Date
Git-CI 47f424252f ci: bump dev version to 0.1.0-dev15 [skip ci] 2026-08-15 04:37:16 +02:00
Marcel Peterkau ac161e8838 Merge pull request 'Feature/sepa export selection' (#21) from feature/sepa-export-selection into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/21
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-15 02:35:32 +00:00
Marcel Peterkau b7554478ac Merge remote-tracking branch 'origin/dev' into feature/sepa-export-selection 2026-08-15 04:33:46 +02:00
Git-CI cba463a648 ci: bump dev version to 0.1.0-dev14 [skip ci] 2026-08-15 04:06:10 +02:00
Marcel Peterkau f5c8d81057 Merge pull request 'Feature/housekeeper overdue anniversary fixes' (#20) from feature/housekeeper-overdue-anniversary-fixes into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/20
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-15 02:04:34 +00:00
Marcel Peterkau 85a865fa0e Merge branch 'dev' into feature/sepa-export-selection 2026-08-15 02:02:14 +00:00
Marcel Peterkau 4951d81ea3 Merge branch 'dev' into feature/housekeeper-overdue-anniversary-fixes 2026-08-15 02:02:02 +00:00
Git-CI e7fdde8691 ci: bump dev version to 0.1.0-dev13 [skip ci] 2026-08-15 04:01:12 +02:00
Marcel Peterkau 4e2595cec4 Merge pull request 'Feature/failed direct debit' (#19) from feature/failed-direct-debit into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/19
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-15 01:59:06 +00:00
Marcel Peterkau aa5829acb0 Merge branch 'dev' into feature/failed-direct-debit 2026-08-15 01:58:19 +00:00
Git-CI 76e0e8326c ci: bump dev version to 0.1.0-dev12 [skip ci] 2026-08-15 03:55:12 +02:00
Marcel Peterkau f861732131 Merge pull request 'Feature/housekeeper run single member' (#18) from feature/housekeeper-run-single-member into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/18
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-15 01:53:27 +00:00
Marcel PeterkauandClaude Sonnet 5 2ce764b007 Document the SEPA export multi-select feature
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:51:52 +02:00
Marcel PeterkauandClaude Sonnet 5 bfb65b2583 Let individual SEPA debits be excluded from an export, not just all-or-nothing
The SEPA export dialog always exported every pending direct debit in the
filtered date range with no way to hold one back -- e.g. a member whose
Rücklastschrift is still being clarified had to either be collected again
regardless or the whole run skipped. The table now supports multi-select
(everything selected by default) and the CSV/XML/mail exports only include
the currently selected rows. Deselection survives the refresh() that
_prepare() runs right before exporting (tracked by member_id, not row index,
so a debit newly appearing after a requery still defaults to selected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:51:32 +02:00
Marcel PeterkauandClaude Sonnet 5 d87a57775e Document the SEPA overdue-during-active-Mahnung fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:45:49 +02:00
Marcel PeterkauandClaude Sonnet 5 25f5d51d77 Stop flagging SEPA members with a running Rücklastschrift-Mahnung as overdue
sepa_debit_overdue fired unconditionally for every overdue SEPA claim past the
grace period, regardless of whether a reminder had already been sent for it --
so a Rücklastschrift-Mahnung created via the reminder mechanism made no
difference to the housekeeper output. Check for an already-sent reminder whose
own payment deadline hasn't expired yet first; if there is one, show a
low-priority "Rücklastschriftklärung läuft" note instead, mirroring the
non-SEPA "reminder_awaiting_deadline" fix. Once that deadline passes without
resolution, the plain sepa_debit_overdue notice returns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:45:27 +02:00
Marcel PeterkauandClaude Sonnet 5 a1fb2f4e30 Refresh the member tab after a single-member housekeeper run
_execute_housekeeper_run() showed the success dialog but never called
refresh()/on_changed(), so retroactively created claims/payments stayed
invisible in the currently open Mitgliedsakte until it was closed and
reopened, and the overview list wasn't notified either. Match the pattern
used by every other mutating action in this file (save, payment, credit,
reminder, ...): refresh the tab's own contribution lists and notify the
overview callback right after the run succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:40:40 +02:00
Marcel PeterkauandClaude Sonnet 5 0641285681 Document the housekeeper overdue-severity and ended-member reminder fixes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:32:07 +02:00
Marcel PeterkauandClaude Sonnet 5 56a3f5f037 Fix housekeeper false-positive overdue errors and ended-member birthday/anniversary noise
An overdue claim whose reminder had already been sent, with that reminder's own
payment deadline still running, fell through to the plain "überfällig" error
finding whenever reminder_due's escalation check returned None to signal "not
yet time for the next Mahnstufe" -- that None was indistinguishable from "never
handled at all" to the caller, so it looked neglected even though a reminder was
already out. Give that case its own low-priority "reminder_awaiting_deadline"
finding instead of silently falling back to the overdue-error path.

Birthday and membership-anniversary findings also kept firing for members whose
status is "ended", which doesn't make sense once the membership itself is over.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:20:18 +02:00
Marcel PeterkauandClaude Sonnet 5 4ecdf87d05 Document the multi-item/Rücklastschrift reminder feature and reminder editing
Catches up the changelog for the reminder items table, the configurable
Mahnungen policy in Options, and today's ability to edit reminders (incl.
undoing an already-sent one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:23 +02:00
Marcel PeterkauandClaude Sonnet 5 186af530b0 Allow editing reminders (incl. undoing "sent") and prefill a Rücklastschrift reason
A generated/sent Mahnung email couldn't be corrected before: sent reminders were
locked, so a wording mistake meant living with it. Add edit_reminder_draft() for
draft/generated reminders and revert_reminder_sent() to safely undo a "sent"
reminder (removing the fee items it booked, refusing if a payment already covers
them or a higher Mahnstufe already exists) so it can be edited and resent.

ReminderDialog now supports an edit mode (reminder=... prefills name/detail/
items/deadline/channel, level stays fixed) and claim_tab gained a "Mahnung
bearbeiten" button that reverts-then-edits for sent reminders automatically,
asking for confirmation first since it un-books the fee.

The Rücklastschrift preset now also prefills "Details" with a short explanation
("Leider konnte die Lastschrift ... nicht eingelöst werden.") since that field
goes straight into the reminder email -- the board can append the concrete
reason (mangels Deckung, Konto ungültig, ...) right there instead of the field
starting empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 f3bdffd347 Make ReminderDialog resizable/wider and fix Options dialog bottom clipping
ReminderDialog: override the shared dialog base's resizable(False, False),
widen the initial layout, and pin the items-table edit row's amount field
to the exact same pixel width as the table's fixed "amount" column (via a
pack_propagate(False) frame) while the description field stretches like
the table's own column does -- keeps alignment exact even after resizing.

OptionsDialog: compute the window's initial geometry from its actual
required size after all tabs (incl. "Mahnungen") are built, instead of a
static guess that clipped the Mahnungen tab's tables/buttons at the bottom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 3940926a22 Line up the reminder items edit row with the table columns
Beschreibung/Betrag now sit on their own row directly under the table,
sized to the same 220:100 ratio as the table's own columns, with the
Hinzufügen/Aktualisieren/Entfernen buttons moved to a row below that
instead of everything being crammed into one row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 d4dfd0066a Fix reminder dialog sizing, item editing, and old-repo fee defaults
The dialog's base class sets resizable(False, False) before this
subclass's content (including the preset-populated items table) exists,
so its initial size stayed locked to a too-small guess and cut off the
bottom. It now explicitly sizes to its actual content after everything,
including the selected preset's items, has been built.

The items table only supported add/remove -- there was no way to change
an already-added row's amount (e.g. after picking the Rücklastschrift
preset, its prefilled fee couldn't be adjusted). Selecting a row now
loads it into the description/amount fields, and a new "Aktualisieren"
button applies edits back to that row.

The Rücklastschrift preset label was missing the "Stufe N:" prefix the
other presets have, inconsistent for no reason.

Also fixed a real gap: repositories created before standard_fee_items
existed had no such key in repository.json at all, so Optionen showed
an empty Standardpositionen table instead of the built-in defaults.
get_reminder_policy() now backfills the defaults when the key is
missing entirely, while still respecting a list the board intentionally
emptied and saved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 a1719cad5e Handle failed direct debits through the same reminder mechanism
A Rücklastschrift is still the member's own failure to ensure cover or
provide valid bank details, so it belongs in the same escalation ladder
as a regular reminder -- it now occupies whatever level the claim is next
due for, alongside a "Rücklastschrift" preset next to the existing
Mahnstufe presets.

Mahnung vorbereiten no longer lets the board pick a level (it's computed
automatically from what's already been sent, since the sequencing was
already server-enforced) and replaces the single fee field with a small
items table (Beschreibung + Betrag), so a reminder can carry several
charges at once -- e.g. Rücklastschriftgebühr plus Bankgebühr plus Porto
-- each landing on the claim as its own line item instead of one lump
sum under a generic label. The description field offers the configured
standard texts but stays free-editable.

Those standard texts and amounts, plus the dunning levels themselves
(name/fee/deadline), are now configurable from Optionen -> Mahnungen
instead of only being editable by hand-editing repository.json, which
was the case for the whole reminder policy until now. Cleaned up
reminder_fee/failed_debit_fee on contribution rules while at it -- both
were unused leftovers superseded by this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 46795fc110 Reword the single-member housekeeper changelog entry so the PR carries a real diff
A prior push mixup (feature/member-contribution-overrides' origin ref had
briefly advanced to include this branch's own "Document the single-member
housekeeper run feature" commit) meant that commit's changelog line already
rode along into dev through PR #17 -- without the actual feature code, which
only exists here. CI's PR gate expects CHANGELOG.json to differ from dev, but
the merged text was already byte-identical, so this branch's PR couldn't pass.
Rewording the entry (adding the actual "Mitgliedsakte" entry point) keeps it
accurate and gives this branch a real changelog diff again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:12:44 +02:00
Marcel Peterkau 227ba8b128 Merge branch 'dev' into feature/housekeeper-run-single-member 2026-08-14 23:35:49 +00:00
Marcel PeterkauandClaude Sonnet 5 ee99577b20 Run the housekeeper for a single member, optionally retroactively
Add a "Hausmeister für dieses Mitglied" button to the member view. It
runs every rule scoped to just that member -- e.g. after editing a
contribution override or payment frequency, to recompute their claims on
demand instead of waiting for or triggering a full run over every
member. A checkbox lets the board force retroactive claim creation for
just that one run, without changing the application-wide default
setting.

Housekeeper.run() gained member_id and retroactive_claims parameters.
Scoping to one member skips the asset checks and leaves every other
member's tasks completely untouched, since their rules never ran this
pass and so fall outside the resolved-scope set that
_resolve_stale_tasks uses to decide what to clear.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:59:01 +02:00
19 changed files with 1541 additions and 124 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0-dev11
0.1.0-dev15
+8 -1
View File
@@ -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."
]
},
{
+7
View File
@@ -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 "")
+2
View File
@@ -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:
+78 -2
View File
@@ -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:
+14 -1
View File
@@ -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
+44 -22
View File
@@ -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(
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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)
+73 -4
View File
@@ -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)
+248 -2
View File
@@ -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
View File
@@ -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):
+3 -3
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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