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
+191 -2
View File
@@ -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"):