Merge pull request 'Feature/member contribution overrides' (#17) from feature/member-contribution-overrides into dev

Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/17
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
This commit is contained in:
Marcel Peterkau
2026-08-14 23:29:05 +00:00
10 changed files with 886 additions and 6 deletions
+3 -1
View File
@@ -27,7 +27,9 @@
"Die Spaltenbreiten in den Tabellen für Forderungen, Zahlungen, Spenden und Hausmeister-Vorgänge sind jetzt einheitlich: Datum-, Betrag- und Statusspalten haben eine feste Breite, nur die abschließende Beschreibungs- bzw. Referenzspalte passt sich dynamisch an die Fensterbreite an.",
"Die Chronik im Mitgliedsfenster nimmt standardmäßig ein Drittel der Fensterbreite ein.",
"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."
"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."
]
},
{
+25
View File
@@ -25,6 +25,11 @@ DONATION_STATUS_LABELS = {
"overallocated": "ÜBERZUGEORDNET",
}
CONTRIBUTION_OVERRIDE_KIND_LABELS = {
"amount": "BETRAG",
"percent": "PROZENT",
}
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
text = str(value).strip().replace(",", ".")
@@ -148,6 +153,26 @@ def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
return "open"
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 "")
if valid_from and month < valid_from:
return False
if valid_until and month > valid_until:
return False
return True
def contribution_override_ranges_overlap(
valid_from: str, valid_until: str, other_from: str, other_until: str
) -> bool:
"""Month ranges ('YYYY-MM', empty valid_until/other_until = open-ended) overlap
if each range starts no later than the other one ends."""
starts_before_other_ends = not other_until or valid_from <= other_until
other_starts_before_ends = not valid_until or other_from <= valid_until
return starts_before_other_ends and other_starts_before_ends
def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str:
if str(claim.get("status", "")) == "cancelled":
return "cancelled"
+32
View File
@@ -78,6 +78,38 @@ def normalize_date_input(value: str, field_name: str) -> str:
return parsed.isoformat() if parsed else ""
def month_input_hint() -> str:
return "MM.YYYY" if system_date_pattern() == "%d.%m.%Y" else "YYYY-MM"
def parse_month_input(value: str, field_name: str, *, allow_empty: bool = True) -> str:
"""Parses a month-only value (no day) and normalizes it to 'YYYY-MM', which also
sorts and compares correctly as a plain string."""
text = value.strip()
if not text:
if allow_empty:
return ""
raise DateValidationError(f"{field_name} ist erforderlich.")
for pattern, expected in (("%m.%Y", r"\d{2}\.\d{4}"), ("%Y-%m", r"\d{4}-\d{2}")):
if not re.fullmatch(expected, text):
continue
try:
parsed = datetime.strptime(text, pattern)
except ValueError:
continue
return f"{parsed.year:04d}-{parsed.month:02d}"
raise DateValidationError(f"{field_name} muss ein gültiger Monat im Format {month_input_hint()} sein.")
def format_month_for_display(value: str) -> str:
text = value.strip()
match = re.fullmatch(r"(\d{4})-(\d{2})", text)
if not match:
return text
year, month = match.groups()
return f"{month}.{year}" if system_date_pattern() == "%d.%m.%Y" else f"{year}-{month}"
def format_date_for_display(value: str) -> str:
text = value.strip()
if not text:
+3
View File
@@ -324,6 +324,7 @@ class ContributionData:
allocations: list[dict[str, Any]] = field(default_factory=list)
reminders: list[dict[str, Any]] = field(default_factory=list)
donations: list[dict[str, Any]] = field(default_factory=list)
contribution_overrides: list[dict[str, Any]] = field(default_factory=list)
schema_version: int = 1
def to_dict(self) -> dict[str, Any]:
@@ -335,6 +336,7 @@ class ContributionData:
"allocations": self.allocations,
"reminders": self.reminders,
"donations": self.donations,
"contribution_overrides": self.contribution_overrides,
}
@classmethod
@@ -347,6 +349,7 @@ class ContributionData:
allocations=list(data.get("allocations") or []),
reminders=list(data.get("reminders") or []),
donations=list(data.get("donations") or []),
contribution_overrides=list(data.get("contribution_overrides") or []),
)
+36 -2
View File
@@ -2,6 +2,7 @@ import calendar
from datetime import date, timedelta
from decimal import ROUND_HALF_UP, Decimal
from ccma.domain.contributions import contribution_override_covers_month
from ccma.domain.dates import DateValidationError, parse_iso_date
from ccma.rules.api import RuleContext, create_claim
from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
@@ -124,7 +125,7 @@ def _membership_claims(
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
actions = []
monthly_amount = annual_amount / Decimal(12)
overrides = context.contributions.contribution_overrides
for suffix, first_month, last_month, regular_due in periods:
# The entry year is intentionally billed from the entry month onward,
# even when retroactive claims create old membership-fee claims.
@@ -132,7 +133,17 @@ def _membership_claims(
months = max(0, last_month - charged_from + 1)
if months == 0:
continue
amount = (monthly_amount * months).quantize(CENT, rounding=ROUND_HALF_UP)
# Blended month by month rather than a single rate for the whole period, so a
# contribution override that starts or ends mid-period (e.g. a student discount
# beginning in March for a member who pays semiannually) is billed correctly.
month_amounts = [
_monthly_amount(context.repository_config, overrides, year, month)
for month in range(charged_from, last_month + 1)
]
amount = sum((value for value, _override_id in month_amounts), Decimal("0")).quantize(
CENT, rounding=ROUND_HALF_UP
)
applied_overrides = sorted({override_id for _value, override_id in month_amounts if override_id})
entry_year = started_at.year == year
issue_date = regular_due - timedelta(days=issue_days)
if not entry_year and context.today < issue_date:
@@ -168,6 +179,7 @@ def _membership_claims(
"annual_amount": _money(annual_amount),
"months": months,
"formula": "annual_amount * months / 12",
"contribution_override_ids": applied_overrides,
},
},
)
@@ -175,6 +187,28 @@ def _membership_claims(
return actions
def _monthly_amount(config, overrides, year: int, month: int) -> tuple[Decimal, str]:
"""Returns the effective monthly rate for a single calendar month, and the
override's id if one applied (empty string otherwise)."""
rule = _rule_for(config, date(year, month, 1))
base_annual = Decimal(str(rule.get("annual_amount", "0"))) if rule else Decimal("0")
month_key = f"{year:04d}-{month:02d}"
override = next(
(item for item in overrides if contribution_override_covers_month(item, month_key)), None
)
if override is None:
return base_annual / Decimal(12), ""
kind = str(override.get("kind", ""))
value = Decimal(str(override.get("value", "0")))
if kind == "amount":
annual = value
elif kind == "percent":
annual = base_annual * (Decimal(100) - value) / Decimal(100)
else:
annual = base_annual
return annual / Decimal(12), str(override.get("override_id", ""))
def _rule_for(config, target: date):
selected = None
for rule in config.get("contribution_rules", []):
+191 -2
View File
@@ -14,9 +14,11 @@ from string import Formatter
from uuid import uuid4
from ccma.domain.contributions import (
CONTRIBUTION_OVERRIDE_KIND_LABELS,
allocated_total,
claim_balance,
claim_total,
contribution_override_ranges_overlap,
credit_allocated_total,
decimal_value,
donation_balance,
@@ -24,7 +26,12 @@ from ccma.domain.contributions import (
money_text,
payment_allocated_total,
)
from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates
from ccma.domain.dates import (
DateValidationError,
normalize_date_input,
parse_month_input,
validate_member_dates,
)
from ccma.domain.models import (
ASSET_CUSTODY_TYPE_LABELS,
ASSET_OWNER_TYPE_LABELS,
@@ -811,7 +818,15 @@ class MemberRepository:
raw = read_json(path)
if not isinstance(raw, dict):
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
for field_name in ("claims", "payments", "credits", "allocations", "reminders"):
for field_name in (
"claims",
"payments",
"credits",
"allocations",
"reminders",
"donations",
"contribution_overrides",
):
if field_name in raw and not isinstance(raw[field_name], list):
raise TypeError(f"{field_name} muss eine JSON-Liste sein")
if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]):
@@ -1786,6 +1801,171 @@ class MemberRepository:
)
return allocation
def get_contribution_override(self, member_id: str, override_id: str) -> tuple[ContributionData, dict]:
data = self.get_contributions(member_id)
override = next(
(
item
for item in data.contribution_overrides
if str(item.get("override_id", "")) == override_id
),
None,
)
if override is None:
raise RepositoryError(f"Abweichender Beitrag nicht gefunden: {override_id}")
return data, override
def record_contribution_override(
self,
member_id: str,
*,
valid_from: str,
valid_until: str = "",
kind: str,
value: str,
reason: str,
actor_name: str = "Vorstand",
) -> dict:
self.get_member(member_id)
normalized = self._normalize_contribution_override_input(
valid_from=valid_from, valid_until=valid_until, kind=kind, value=value, reason=reason
)
normalized_from, normalized_until, normalized_kind, normalized_value, normalized_reason = normalized
data = self.get_contributions(member_id)
self._assert_contribution_override_range_free(data, normalized_from, normalized_until)
override = {
"override_id": str(uuid4()),
"valid_from": normalized_from,
"valid_until": normalized_until,
"kind": normalized_kind,
"value": normalized_value,
"reason": normalized_reason,
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"created_by": actor_name,
}
data.contribution_overrides.append(override)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="contribution_override_recorded",
summary=f"Abweichender Mitgliedsbeitrag erfasst: {_contribution_override_summary(override)}",
actor_type="user",
actor_name=actor_name,
references={"override_id": override["override_id"]},
)
return override
def update_contribution_override(
self,
member_id: str,
override_id: str,
*,
valid_from: str,
valid_until: str = "",
kind: str,
value: str,
reason: str,
actor_name: str = "Vorstand",
) -> dict:
normalized = self._normalize_contribution_override_input(
valid_from=valid_from, valid_until=valid_until, kind=kind, value=value, reason=reason
)
normalized_from, normalized_until, normalized_kind, normalized_value, normalized_reason = normalized
data, override = self.get_contribution_override(member_id, override_id)
self._assert_contribution_override_range_free(
data, normalized_from, normalized_until, exclude_override_id=override_id
)
override.update(
{
"valid_from": normalized_from,
"valid_until": normalized_until,
"kind": normalized_kind,
"value": normalized_value,
"reason": normalized_reason,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="contribution_override_changed",
summary=f"Abweichender Mitgliedsbeitrag geändert: {_contribution_override_summary(override)}",
actor_type="user",
actor_name=actor_name,
references={"override_id": override_id},
)
return override
def delete_contribution_override(
self, member_id: str, override_id: str, *, actor_name: str = "Vorstand"
) -> None:
data, override = self.get_contribution_override(member_id, override_id)
data.contribution_overrides = [
item
for item in data.contribution_overrides
if str(item.get("override_id", "")) != override_id
]
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="contribution_override_deleted",
summary=f"Abweichender Mitgliedsbeitrag gelöscht: {_contribution_override_summary(override)}",
actor_type="user",
actor_name=actor_name,
references={"override_id": override_id},
)
@staticmethod
def _normalize_contribution_override_input(
*, valid_from: str, valid_until: str, kind: str, value: str, reason: str
) -> tuple[str, str, str, str, str]:
try:
normalized_from = parse_month_input(valid_from, "Ab", allow_empty=False)
normalized_until = parse_month_input(valid_until, "Bis")
except DateValidationError as exc:
raise RepositoryError(str(exc)) from exc
if normalized_until and normalized_until < normalized_from:
raise RepositoryError("Der Zeitraum 'Bis' darf nicht vor dem Zeitraum 'Ab' liegen.")
normalized_kind = kind.strip().lower()
if normalized_kind not in CONTRIBUTION_OVERRIDE_KIND_LABELS:
raise RepositoryError("Ungültige Art des abweichenden Beitrags.")
try:
parsed_value = decimal_value(value, "Wert")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if normalized_kind == "percent":
if parsed_value <= 0 or parsed_value > 100:
raise RepositoryError("Die Ermäßigung muss zwischen 0 und 100 Prozent liegen.")
elif parsed_value < 0:
raise RepositoryError("Der Jahresbetrag darf nicht negativ sein.")
normalized_reason = reason.strip()
if not normalized_reason:
raise RepositoryError("Eine Begründung ist erforderlich.")
return normalized_from, normalized_until, normalized_kind, money_text(parsed_value), normalized_reason
@staticmethod
def _assert_contribution_override_range_free(
data: ContributionData,
valid_from: str,
valid_until: str,
*,
exclude_override_id: str | None = None,
) -> None:
for existing in data.contribution_overrides:
if exclude_override_id and str(existing.get("override_id", "")) == exclude_override_id:
continue
if contribution_override_ranges_overlap(
valid_from,
valid_until,
str(existing.get("valid_from", "")),
str(existing.get("valid_until", "") or ""),
):
existing_until = str(existing.get("valid_until", "") or "") or "unbefristet"
raise RepositoryError(
"Der Zeitraum überschneidet sich mit einem bereits vorhandenen abweichenden "
f"Beitrag ({existing.get('valid_from', '')} bis {existing_until})."
)
def create_reminder_draft(
self,
member_id: str,
@@ -2455,6 +2635,15 @@ def _german_date(value: str) -> str:
return ""
def _contribution_override_summary(override: dict) -> str:
value = str(override.get("value", ""))
value_text = f"{value}%" if str(override.get("kind", "")) == "percent" else f"{value} EUR"
valid_from = str(override.get("valid_from", ""))
valid_until = str(override.get("valid_until", "") or "")
period = f"{valid_from} bis {valid_until}" if valid_until else f"ab {valid_from}"
return f"{value_text} ({period})"
def _dunning_hold_is_active(claim: dict) -> bool:
hold = claim.get("dunning_hold") or {}
if not hold.get("active"):
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from tkinter import messagebox, ttk
from ccma.domain.contributions import CONTRIBUTION_OVERRIDE_KIND_LABELS
from ccma.domain.dates import format_month_for_display, month_input_hint
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.labels import storage_key
class _Dialog(tk.Toplevel):
def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]):
super().__init__(master)
self.on_saved = on_saved
self.title(title)
self.transient(master.winfo_toplevel())
self.resizable(False, False)
self.frame = ttk.Frame(self, padding=18)
self.frame.pack(fill="both", expand=True)
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self.grab_set)
def _buttons(self, row: int, command: Callable[[], None]) -> None:
buttons = ttk.Frame(self.frame)
buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
class ContributionOverrideEditDialog(_Dialog):
"""Records an individually agreed membership fee for a member -- e.g. a reduced
student rate -- for a month-granular date range, either as a fixed annual amount
or as a percentage discount off whichever base rate is in effect at the time."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_saved: Callable[[], None],
override: dict | None = None,
):
super().__init__(
master,
"Abweichenden Beitrag bearbeiten" if override else "Abweichenden Beitrag anlegen",
on_saved,
)
self.repository = repository
self.member_id = member_id
self.override_id = str(override.get("override_id")) if override else None
kind_label = CONTRIBUTION_OVERRIDE_KIND_LABELS.get(
str(override.get("kind", "")) if override else "amount",
CONTRIBUTION_OVERRIDE_KIND_LABELS["amount"],
)
self.variables = {
"valid_from": tk.StringVar(
value=format_month_for_display(str(override.get("valid_from", ""))) if override else ""
),
"valid_until": tk.StringVar(
value=format_month_for_display(str(override.get("valid_until", ""))) if override else ""
),
"kind": tk.StringVar(value=kind_label),
"value": tk.StringVar(value=str(override.get("value", "")) if override else ""),
}
self.frame.columnconfigure(1, weight=1)
ttk.Label(self.frame, text=f"Ab ({month_input_hint()})").grid(
row=0, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.variables["valid_from"], width=42).grid(
row=0, column=1, sticky="ew", pady=5
)
ttk.Label(self.frame, text=f"Bis ({month_input_hint()}, leer = unbefristet)").grid(
row=1, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.variables["valid_until"], width=42).grid(
row=1, column=1, sticky="ew", pady=5
)
ttk.Label(self.frame, text="Art").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
kind_combo = ttk.Combobox(
self.frame,
textvariable=self.variables["kind"],
values=list(CONTRIBUTION_OVERRIDE_KIND_LABELS.values()),
state="readonly",
width=39,
)
kind_combo.grid(row=2, column=1, sticky="ew", pady=5)
kind_combo.bind("<<ComboboxSelected>>", lambda _event: self._update_value_label())
self.value_label_var = tk.StringVar()
ttk.Label(self.frame, textvariable=self.value_label_var).grid(
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.variables["value"], width=42).grid(
row=3, column=1, sticky="ew", pady=5
)
ttk.Label(self.frame, text="Begründung").grid(row=4, column=0, sticky="nw", pady=5, padx=(0, 12))
self.reason_text = tk.Text(self.frame, width=42, height=4, wrap="word")
self.reason_text.grid(row=4, column=1, sticky="ew", pady=5)
if override:
self.reason_text.insert("1.0", str(override.get("reason", "")))
self._update_value_label()
self._buttons(5, self._save)
def _update_value_label(self) -> None:
kind = storage_key(CONTRIBUTION_OVERRIDE_KIND_LABELS, self.variables["kind"].get())
self.value_label_var.set("Jahresbetrag (EUR)" if kind == "amount" else "Ermäßigung (%)")
def _save(self) -> None:
kind = storage_key(CONTRIBUTION_OVERRIDE_KIND_LABELS, self.variables["kind"].get())
reason = self.reason_text.get("1.0", "end-1c")
try:
if self.override_id:
self.repository.update_contribution_override(
self.member_id,
self.override_id,
valid_from=self.variables["valid_from"].get(),
valid_until=self.variables["valid_until"].get(),
kind=kind,
value=self.variables["value"].get(),
reason=reason,
)
else:
self.repository.record_contribution_override(
self.member_id,
valid_from=self.variables["valid_from"].get(),
valid_until=self.variables["valid_until"].get(),
kind=kind,
value=self.variables["value"].get(),
reason=reason,
)
except RepositoryError as exc:
messagebox.showerror(
"Abweichender Beitrag konnte nicht gespeichert werden", str(exc), parent=self
)
return
self.destroy()
self.on_saved()
+136 -1
View File
@@ -10,6 +10,7 @@ from tkinter import messagebox, ttk
from ccma.config import AppConfig
from ccma.domain.contributions import (
CLAIM_STATUS_LABELS,
CONTRIBUTION_OVERRIDE_KIND_LABELS,
DONATION_STATUS_LABELS,
claim_status,
claim_total,
@@ -19,10 +20,11 @@ from ccma.domain.contributions import (
money_text,
payment_allocated_total,
)
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
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 MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
from ccma.ui.dialogs import IntegrityWarningDialog
from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.donation_dialog import (
@@ -174,11 +176,13 @@ class MemberTab(ttk.Frame):
command=self._save,
).grid(row=0, column=0, sticky="e")
contribution_tab = ttk.Frame(notebook, padding=16)
contribution_override_tab = ttk.Frame(notebook, padding=16)
payments_tab = ttk.Frame(notebook, padding=16)
donations_tab = ttk.Frame(notebook, padding=16)
assets_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(contribution_tab, text="Forderungen")
notebook.add(contribution_override_tab, text="Beitrag")
notebook.add(payments_tab, text="Zahlungen")
notebook.add(donations_tab, text="Spenden")
notebook.add(assets_tab, text="Assets")
@@ -302,6 +306,58 @@ class MemberTab(ttk.Frame):
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
contribution_override_tab.columnconfigure(0, weight=1)
contribution_override_tab.rowconfigure(1, weight=1)
ttk.Label(
contribution_override_tab,
text=(
"Individuell vereinbarte Beiträge (z. B. ermäßigter Beitrag für Schüler), die von "
"den regulären Beitragssätzen abweichen. Wirkt sich erst auf künftig erzeugte "
"Forderungen aus, bereits erzeugte Forderungen bleiben unverändert."
),
style="Mono.TLabel",
wraplength=700,
).grid(row=0, column=0, sticky="w", pady=(0, 10))
self.contribution_overrides = ttk.Treeview(
contribution_override_tab,
columns=("valid_from", "valid_until", "kind", "value", "reason"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("valid_from", "Ab", FIXED_COLUMN_WIDTH),
("valid_until", "Bis", FIXED_COLUMN_WIDTH),
("kind", "Art", FIXED_COLUMN_WIDTH),
("value", "Wert", FIXED_COLUMN_WIDTH),
("reason", "Begründung", 320),
):
self.contribution_overrides.heading(key, text=title)
self.contribution_overrides.column(key, width=width, anchor="w", stretch=key == "reason")
self.contribution_overrides.grid(row=1, column=0, sticky="nsew")
self.contribution_overrides.bind(
"<Double-1>", lambda _event: self._edit_selected_contribution_override()
)
self.contribution_overrides.bind(
"<Return>", lambda _event: self._edit_selected_contribution_override()
)
contribution_override_actions = ttk.Frame(contribution_override_tab)
contribution_override_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(
contribution_override_actions,
text="Abweichenden Beitrag anlegen",
command=self._create_contribution_override,
).pack(side="left", padx=(0, 8))
ttk.Button(
contribution_override_actions,
text="Bearbeiten",
command=self._edit_selected_contribution_override,
).pack(side="left", padx=(0, 8))
ttk.Button(
contribution_override_actions,
text="Löschen",
command=self._delete_selected_contribution_override,
).pack(side="left")
payments_tab.columnconfigure(0, weight=1)
payments_tab.rowconfigure(1, weight=1)
self.payment_summary = tk.StringVar()
@@ -573,6 +629,7 @@ class MemberTab(ttk.Frame):
self._clear_dirty()
self._refresh_events()
self._refresh_contributions()
self._refresh_contribution_overrides()
self._refresh_donations()
self._refresh_assets()
self._refresh_documents()
@@ -686,6 +743,31 @@ class MemberTab(ttk.Frame):
)
self.donation_summary.set(f"{len(data.donations)} Spenden · Gesamt {money_text(total_amount)} EUR")
def _refresh_contribution_overrides(self) -> None:
self.contribution_overrides.delete(*self.contribution_overrides.get_children())
try:
data = self.repository.get_contributions(self.member_id)
except RepositoryError:
return
for override in sorted(
data.contribution_overrides, key=lambda item: str(item.get("valid_from", "")), reverse=True
):
kind = str(override.get("kind", ""))
value = str(override.get("value", ""))
value_text = f"{value} %" if kind == "percent" else f"{value} EUR"
self.contribution_overrides.insert(
"",
"end",
iid=str(override.get("override_id", "")),
values=(
format_month_for_display(str(override.get("valid_from", ""))),
format_month_for_display(str(override.get("valid_until", ""))) or "unbefristet",
CONTRIBUTION_OVERRIDE_KIND_LABELS.get(kind, kind.upper()),
value_text,
override.get("reason", ""),
),
)
def _toggle_claim_sort(self, column: str) -> None:
if self.claim_sort_column == column:
self.claim_sort_descending = not self.claim_sort_descending
@@ -834,6 +916,59 @@ class MemberTab(ttk.Frame):
self.refresh()
self.on_changed()
def _selected_contribution_override(self) -> dict | None:
selected = self.contribution_overrides.selection()
if not selected:
return None
try:
_data, override = self.repository.get_contribution_override(self.member_id, selected[0])
except RepositoryError:
return None
return override
def _create_contribution_override(self) -> None:
ContributionOverrideEditDialog(
self, self.repository, self.member_id, self._contribution_override_changed
)
def _edit_selected_contribution_override(self) -> None:
override = self._selected_contribution_override()
if not override:
messagebox.showinfo(
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
)
return
ContributionOverrideEditDialog(
self, self.repository, self.member_id, self._contribution_override_changed, override
)
def _delete_selected_contribution_override(self) -> None:
override = self._selected_contribution_override()
if not override:
messagebox.showinfo(
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
)
return
if not messagebox.askyesno(
"Abweichenden Beitrag löschen",
"Diesen abweichenden Beitrag wirklich löschen? Bereits erzeugte Forderungen bleiben "
"davon unberührt.",
parent=self,
):
return
try:
self.repository.delete_contribution_override(
self.member_id, str(override.get("override_id", ""))
)
except RepositoryError as exc:
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
return
self._contribution_override_changed()
def _contribution_override_changed(self) -> None:
self.refresh()
self.on_changed()
def _refresh_documents(self) -> None:
self.documents.delete(*self.documents.get_children())
self.document_paths.clear()
+300
View File
@@ -0,0 +1,300 @@
from datetime import date
import pytest
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError
def _member(tmp_path):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Override", last_name="Test", birth_date="2010-01-01")
member.status = "active"
member.accepted_at = "2026-01-01"
member.membership_started_at = "2026-01-01"
member.payment_frequency = "semiannual"
repository.save_member(member)
return repository, member
def test_contribution_override_can_be_recorded_with_amount(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id,
valid_from="03.2026",
valid_until="",
kind="amount",
value="90.00",
reason="Schüler laut Nachweis",
)
data = repository.get_contributions(member.member_id)
assert data.contribution_overrides == [override]
assert override["valid_from"] == "2026-03"
assert override["valid_until"] == ""
assert override["kind"] == "amount"
assert override["value"] == "90.00"
assert override["reason"] == "Schüler laut Nachweis"
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_recorded"
def test_contribution_override_accepts_percent_kind(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id,
valid_from="2026-03",
valid_until="2026-08",
kind="percent",
value="50",
reason="Schüler laut Nachweis",
)
assert override["kind"] == "percent"
assert override["value"] == "50.00"
assert override["valid_until"] == "2026-08"
def test_contribution_override_requires_reason(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="Begründung"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="amount", value="90.00", reason=" "
)
def test_contribution_override_rejects_invalid_percent(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="zwischen 0 und 100"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="percent", value="150", reason="Test"
)
with pytest.raises(RepositoryError, match="zwischen 0 und 100"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="percent", value="0", reason="Test"
)
def test_contribution_override_rejects_negative_amount(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="nicht negativ"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="amount", value="-10", reason="Test"
)
def test_contribution_override_rejects_until_before_from(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="darf nicht vor"):
repository.record_contribution_override(
member.member_id,
valid_from="08.2026",
valid_until="03.2026",
kind="amount",
value="90.00",
reason="Test",
)
def test_overlapping_contribution_overrides_are_rejected(tmp_path) -> None:
repository, member = _member(tmp_path)
repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-06",
kind="amount",
value="90.00",
reason="Erstes",
)
with pytest.raises(RepositoryError, match="überschneidet sich"):
repository.record_contribution_override(
member.member_id,
valid_from="2026-04",
valid_until="2026-12",
kind="amount",
value="100.00",
reason="Zweites",
)
# Adjacent, non-overlapping range is fine.
second = repository.record_contribution_override(
member.member_id,
valid_from="2026-07",
valid_until="",
kind="amount",
value="100.00",
reason="Zweites, ab Juli",
)
assert second["valid_from"] == "2026-07"
def test_open_ended_override_overlaps_with_any_later_range(tmp_path) -> None:
repository, member = _member(tmp_path)
repository.record_contribution_override(
member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Unbefristet"
)
with pytest.raises(RepositoryError, match="überschneidet sich"):
repository.record_contribution_override(
member.member_id,
valid_from="2030-01",
valid_until="2030-06",
kind="amount",
value="100.00",
reason="Später",
)
def test_contribution_override_can_be_updated_and_deleted(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Erstversion"
)
updated = repository.update_contribution_override(
member.member_id,
override["override_id"],
valid_from="2026-02",
valid_until="2026-12",
kind="percent",
value="40",
reason="Korrigiert",
)
assert updated["valid_from"] == "2026-02"
assert updated["kind"] == "percent"
assert updated["reason"] == "Korrigiert"
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_changed"
repository.delete_contribution_override(member.member_id, override["override_id"])
data = repository.get_contributions(member.member_id)
assert data.contribution_overrides == []
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_deleted"
def test_updating_override_can_keep_its_own_range(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-06",
kind="amount",
value="90.00",
reason="Erstversion",
)
# Saving again with the same range must not trip the overlap check against itself.
updated = repository.update_contribution_override(
member.member_id,
override["override_id"],
valid_from="2026-01",
valid_until="2026-06",
kind="amount",
value="95.00",
reason="Betrag korrigiert",
)
assert updated["value"] == "95.00"
def _housekeeper_member(tmp_path, *, payment_frequency="annual", started_at="2025-01-01"):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Override", last_name="Claims", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = started_at
member.membership_started_at = started_at
member.payment_frequency = payment_frequency
repository.save_member(member)
return repository, member
def test_full_year_amount_override_replaces_the_annual_claim_amount(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-12",
kind="amount",
value="60.00",
reason="Schüler laut Nachweis",
)
Housekeeper(repository).run(today=date(2026, 6, 21))
claims_by_key = {
claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims
}
claim = claims_by_key["membership-fee:2026:annual"]
assert claim["amount"] == "60.00"
assert claim["calculation"]["contribution_override_ids"] != []
def test_full_year_percent_override_reduces_the_base_rate(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-12",
kind="percent",
value="50",
reason="Schüler laut Nachweis",
)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim = next(
claim
for claim in repository.get_contributions(member.member_id).claims
if claim["claim_key"] == "membership-fee:2026:annual"
)
assert claim["amount"] == "75.00"
def test_override_starting_mid_period_blends_month_by_month(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="semiannual")
repository.record_contribution_override(
member.member_id,
valid_from="2026-03",
valid_until="2026-08",
kind="amount",
value="60.00",
reason="Schüler ab März",
)
Housekeeper(repository).run(today=date(2026, 12, 31))
claims_by_key = {
claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims
}
# Jan+Feb at 12.50/month (full rate) + Mar-Jun at 5.00/month (override) = 45.00
assert claims_by_key["membership-fee:2026:first-half"]["amount"] == "45.00"
# Jul+Aug at 5.00/month (override) + Sep-Dec at 12.50/month (full rate) = 60.00
assert claims_by_key["membership-fee:2026:second-half"]["amount"] == "60.00"
def test_claim_without_applicable_override_is_unaffected(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
repository.record_contribution_override(
member.member_id,
valid_from="2020-01",
valid_until="2020-12",
kind="amount",
value="60.00",
reason="Frueherer Zeitraum",
)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim = next(
claim
for claim in repository.get_contributions(member.member_id).claims
if claim["claim_key"] == "membership-fee:2026:annual"
)
assert claim["amount"] == "150.00"
assert claim["calculation"]["contribution_override_ids"] == []
+21
View File
@@ -7,9 +7,11 @@ from ccma.domain.dates import (
age_label,
calculate_age,
format_date_for_display,
format_month_for_display,
normalize_date_input,
parse_date_input,
parse_iso_date,
parse_month_input,
validate_birth_date,
validate_member_dates,
)
@@ -68,6 +70,25 @@ def test_member_dates_must_be_chronological() -> None:
)
def test_month_input_accepts_german_and_iso_formats() -> None:
assert parse_month_input("03.2026", "Ab") == "2026-03"
assert parse_month_input("2026-03", "Ab") == "2026-03"
assert parse_month_input("", "Ab") == ""
with pytest.raises(DateValidationError, match="erforderlich"):
parse_month_input("", "Ab", allow_empty=False)
with pytest.raises(DateValidationError):
parse_month_input("13.2026", "Ab")
with pytest.raises(DateValidationError):
parse_month_input("2026-03-15", "Ab")
def test_month_display_uses_system_pattern(monkeypatch) -> None:
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%d.%m.%Y")
assert format_month_for_display("2026-03") == "03.2026"
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%Y-%m-%d")
assert format_month_for_display("2026-03") == "2026-03"
def test_age_calculation_and_label() -> None:
today = date(2026, 6, 21)
assert calculate_age(date(2000, 6, 21), today) == 26