mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-26 15:25:19 +02:00
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:
co-authored by
Claude Sonnet 5
parent
44228dd7dd
commit
2484a1631d
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import CONTRIBUTION_OVERRIDE_KIND_LABELS
|
||||
from ccma.domain.dates import format_month_for_display, month_input_hint
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.labels import storage_key
|
||||
|
||||
|
||||
class _Dialog(tk.Toplevel):
|
||||
def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]):
|
||||
super().__init__(master)
|
||||
self.on_saved = on_saved
|
||||
self.title(title)
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
self.frame = ttk.Frame(self, padding=18)
|
||||
self.frame.pack(fill="both", expand=True)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _buttons(self, row: int, command: Callable[[], None]) -> None:
|
||||
buttons = ttk.Frame(self.frame)
|
||||
buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
|
||||
|
||||
|
||||
class ContributionOverrideEditDialog(_Dialog):
|
||||
"""Records an individually agreed membership fee for a member -- e.g. a reduced
|
||||
student rate -- for a month-granular date range, either as a fixed annual amount
|
||||
or as a percentage discount off whichever base rate is in effect at the time."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
on_saved: Callable[[], None],
|
||||
override: dict | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
master,
|
||||
"Abweichenden Beitrag bearbeiten" if override else "Abweichenden Beitrag anlegen",
|
||||
on_saved,
|
||||
)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.override_id = str(override.get("override_id")) if override else None
|
||||
kind_label = CONTRIBUTION_OVERRIDE_KIND_LABELS.get(
|
||||
str(override.get("kind", "")) if override else "amount",
|
||||
CONTRIBUTION_OVERRIDE_KIND_LABELS["amount"],
|
||||
)
|
||||
self.variables = {
|
||||
"valid_from": tk.StringVar(
|
||||
value=format_month_for_display(str(override.get("valid_from", ""))) if override else ""
|
||||
),
|
||||
"valid_until": tk.StringVar(
|
||||
value=format_month_for_display(str(override.get("valid_until", ""))) if override else ""
|
||||
),
|
||||
"kind": tk.StringVar(value=kind_label),
|
||||
"value": tk.StringVar(value=str(override.get("value", "")) if override else ""),
|
||||
}
|
||||
|
||||
self.frame.columnconfigure(1, weight=1)
|
||||
ttk.Label(self.frame, text=f"Ab ({month_input_hint()})").grid(
|
||||
row=0, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.variables["valid_from"], width=42).grid(
|
||||
row=0, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(self.frame, text=f"Bis ({month_input_hint()}, leer = unbefristet)").grid(
|
||||
row=1, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.variables["valid_until"], width=42).grid(
|
||||
row=1, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(self.frame, text="Art").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
kind_combo = ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.variables["kind"],
|
||||
values=list(CONTRIBUTION_OVERRIDE_KIND_LABELS.values()),
|
||||
state="readonly",
|
||||
width=39,
|
||||
)
|
||||
kind_combo.grid(row=2, column=1, sticky="ew", pady=5)
|
||||
kind_combo.bind("<<ComboboxSelected>>", lambda _event: self._update_value_label())
|
||||
self.value_label_var = tk.StringVar()
|
||||
ttk.Label(self.frame, textvariable=self.value_label_var).grid(
|
||||
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.variables["value"], width=42).grid(
|
||||
row=3, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(self.frame, text="Begründung").grid(row=4, column=0, sticky="nw", pady=5, padx=(0, 12))
|
||||
self.reason_text = tk.Text(self.frame, width=42, height=4, wrap="word")
|
||||
self.reason_text.grid(row=4, column=1, sticky="ew", pady=5)
|
||||
if override:
|
||||
self.reason_text.insert("1.0", str(override.get("reason", "")))
|
||||
self._update_value_label()
|
||||
self._buttons(5, self._save)
|
||||
|
||||
def _update_value_label(self) -> None:
|
||||
kind = storage_key(CONTRIBUTION_OVERRIDE_KIND_LABELS, self.variables["kind"].get())
|
||||
self.value_label_var.set("Jahresbetrag (EUR)" if kind == "amount" else "Ermäßigung (%)")
|
||||
|
||||
def _save(self) -> None:
|
||||
kind = storage_key(CONTRIBUTION_OVERRIDE_KIND_LABELS, self.variables["kind"].get())
|
||||
reason = self.reason_text.get("1.0", "end-1c")
|
||||
try:
|
||||
if self.override_id:
|
||||
self.repository.update_contribution_override(
|
||||
self.member_id,
|
||||
self.override_id,
|
||||
valid_from=self.variables["valid_from"].get(),
|
||||
valid_until=self.variables["valid_until"].get(),
|
||||
kind=kind,
|
||||
value=self.variables["value"].get(),
|
||||
reason=reason,
|
||||
)
|
||||
else:
|
||||
self.repository.record_contribution_override(
|
||||
self.member_id,
|
||||
valid_from=self.variables["valid_from"].get(),
|
||||
valid_until=self.variables["valid_until"].get(),
|
||||
kind=kind,
|
||||
value=self.variables["value"].get(),
|
||||
reason=reason,
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror(
|
||||
"Abweichender Beitrag konnte nicht gespeichert werden", str(exc), parent=self
|
||||
)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
+136
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user