From 2484a1631d6f2af8a55abb71684f13b395a2a0bc Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 00:37:22 +0200 Subject: [PATCH 1/3] Add individually agreed membership fees (contribution overrides) Members are sometimes given a fee that deviates from the regular schedule -- e.g. a reduced rate for students -- for a specific period. Add a per-member "Beitrag" tab where such deviations can be recorded with a month-granular date range (Ab/Bis), a mandatory reason, and either a fixed annual amount or a percentage discount off whichever base rate is in effect at the time. Data model: ContributionData gets a contribution_overrides list, each entry validated (month format, Bis >= Ab, non-overlapping ranges per member, reason required) and CRUD'd through the repository (record/update/delete/get_contribution_override), consistent with how donations already work. Integration: contribution_claims.py now computes each membership-fee claim's amount month by month instead of a single rate for the whole billing period, picking up whichever override (if any) covers each individual month. That handles an override starting or ending mid period correctly (e.g. a semiannual payer whose discount begins in March) without changing behavior for members without overrides. Already-created claims are never recalculated retroactively, matching how changes to the global contribution rates already behave. Co-Authored-By: Claude Sonnet 5 --- src/ccma/domain/contributions.py | 25 ++ src/ccma/domain/dates.py | 32 ++ src/ccma/domain/models.py | 3 + src/ccma/rules/scripts/contribution_claims.py | 38 ++- src/ccma/storage/repository.py | 193 ++++++++++- src/ccma/ui/contribution_override_dialog.py | 139 ++++++++ src/ccma/ui/member_tab.py | 137 +++++++- tests/test_contribution_overrides.py | 300 ++++++++++++++++++ tests/test_dates.py | 21 ++ 9 files changed, 883 insertions(+), 5 deletions(-) create mode 100644 src/ccma/ui/contribution_override_dialog.py create mode 100644 tests/test_contribution_overrides.py diff --git a/src/ccma/domain/contributions.py b/src/ccma/domain/contributions.py index 1034dec..6d0b581 100644 --- a/src/ccma/domain/contributions.py +++ b/src/ccma/domain/contributions.py @@ -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" diff --git a/src/ccma/domain/dates.py b/src/ccma/domain/dates.py index 4c2ae40..0316920 100644 --- a/src/ccma/domain/dates.py +++ b/src/ccma/domain/dates.py @@ -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: diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index 977b1a7..226e6a3 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -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 []), ) diff --git a/src/ccma/rules/scripts/contribution_claims.py b/src/ccma/rules/scripts/contribution_claims.py index 3da4d2e..ad27e5c 100644 --- a/src/ccma/rules/scripts/contribution_claims.py +++ b/src/ccma/rules/scripts/contribution_claims.py @@ -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", []): diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 826002a..d412d80 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -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"): diff --git a/src/ccma/ui/contribution_override_dialog.py b/src/ccma/ui/contribution_override_dialog.py new file mode 100644 index 0000000..3252790 --- /dev/null +++ b/src/ccma/ui/contribution_override_dialog.py @@ -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("", 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("<>", 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() diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index 2eed828..ee8b058 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -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("", lambda _event: self._open_selected_claim()) self.claims.bind("", 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( + "", lambda _event: self._edit_selected_contribution_override() + ) + self.contribution_overrides.bind( + "", 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() diff --git a/tests/test_contribution_overrides.py b/tests/test_contribution_overrides.py new file mode 100644 index 0000000..0c2d79a --- /dev/null +++ b/tests/test_contribution_overrides.py @@ -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"] == [] diff --git a/tests/test_dates.py b/tests/test_dates.py index d057824..a91471f 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -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 From 1e29cc1e3d2017002fb27b226667f65ca27b2343 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 00:46:31 +0200 Subject: [PATCH 2/3] Document allocation prefill and membership fee override features Co-Authored-By: Claude Sonnet 5 --- src/ccma/assets/CHANGELOG.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 817a2e0..e494b15 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -27,7 +27,8 @@ "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." ] }, { From c07bdff04ea58ca6231404c133aa0a16efedc45d Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 00:55:05 +0200 Subject: [PATCH 3/3] Document the single-member housekeeper run feature Co-Authored-By: Claude Sonnet 5 --- src/ccma/assets/CHANGELOG.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index e494b15..0b1dddf 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -28,7 +28,8 @@ "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.", - "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." + "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." ] }, {