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
+25
View File
@@ -25,6 +25,11 @@ DONATION_STATUS_LABELS = {
"overallocated": "ÜBERZUGEORDNET",
}
CONTRIBUTION_OVERRIDE_KIND_LABELS = {
"amount": "BETRAG",
"percent": "PROZENT",
}
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
text = str(value).strip().replace(",", ".")
@@ -148,6 +153,26 @@ def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
return "open"
def contribution_override_covers_month(override: dict[str, Any], month: str) -> bool:
valid_from = str(override.get("valid_from", ""))
valid_until = str(override.get("valid_until", "") or "")
if valid_from and month < valid_from:
return False
if valid_until and month > valid_until:
return False
return True
def contribution_override_ranges_overlap(
valid_from: str, valid_until: str, other_from: str, other_until: str
) -> bool:
"""Month ranges ('YYYY-MM', empty valid_until/other_until = open-ended) overlap
if each range starts no later than the other one ends."""
starts_before_other_ends = not other_until or valid_from <= other_until
other_starts_before_ends = not valid_until or other_from <= valid_until
return starts_before_other_ends and other_starts_before_ends
def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str:
if str(claim.get("status", "")) == "cancelled":
return "cancelled"
+32
View File
@@ -78,6 +78,38 @@ def normalize_date_input(value: str, field_name: str) -> str:
return parsed.isoformat() if parsed else ""
def month_input_hint() -> str:
return "MM.YYYY" if system_date_pattern() == "%d.%m.%Y" else "YYYY-MM"
def parse_month_input(value: str, field_name: str, *, allow_empty: bool = True) -> str:
"""Parses a month-only value (no day) and normalizes it to 'YYYY-MM', which also
sorts and compares correctly as a plain string."""
text = value.strip()
if not text:
if allow_empty:
return ""
raise DateValidationError(f"{field_name} ist erforderlich.")
for pattern, expected in (("%m.%Y", r"\d{2}\.\d{4}"), ("%Y-%m", r"\d{4}-\d{2}")):
if not re.fullmatch(expected, text):
continue
try:
parsed = datetime.strptime(text, pattern)
except ValueError:
continue
return f"{parsed.year:04d}-{parsed.month:02d}"
raise DateValidationError(f"{field_name} muss ein gültiger Monat im Format {month_input_hint()} sein.")
def format_month_for_display(value: str) -> str:
text = value.strip()
match = re.fullmatch(r"(\d{4})-(\d{2})", text)
if not match:
return text
year, month = match.groups()
return f"{month}.{year}" if system_date_pattern() == "%d.%m.%Y" else f"{year}-{month}"
def format_date_for_display(value: str) -> str:
text = value.strip()
if not text:
+3
View File
@@ -324,6 +324,7 @@ class ContributionData:
allocations: list[dict[str, Any]] = field(default_factory=list)
reminders: list[dict[str, Any]] = field(default_factory=list)
donations: list[dict[str, Any]] = field(default_factory=list)
contribution_overrides: list[dict[str, Any]] = field(default_factory=list)
schema_version: int = 1
def to_dict(self) -> dict[str, Any]:
@@ -335,6 +336,7 @@ class ContributionData:
"allocations": self.allocations,
"reminders": self.reminders,
"donations": self.donations,
"contribution_overrides": self.contribution_overrides,
}
@classmethod
@@ -347,6 +349,7 @@ class ContributionData:
allocations=list(data.get("allocations") or []),
reminders=list(data.get("reminders") or []),
donations=list(data.get("donations") or []),
contribution_overrides=list(data.get("contribution_overrides") or []),
)