mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +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
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user