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 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-15 00:57:51 +02:00
co-authored by Claude Sonnet 5
parent 44228dd7dd
commit 2484a1631d
9 changed files with 883 additions and 5 deletions
+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()