From 2484a1631d6f2af8a55abb71684f13b395a2a0bc Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 00:37:22 +0200 Subject: [PATCH] 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